diff --git a/JobTrackerApi/Controllers/CorrespondenceController.cs b/JobTrackerApi/Controllers/CorrespondenceController.cs index a97ab20..f0c5edc 100644 --- a/JobTrackerApi/Controllers/CorrespondenceController.cs +++ b/JobTrackerApi/Controllers/CorrespondenceController.cs @@ -25,6 +25,84 @@ namespace JobTrackerApi.Controllers .FirstOrDefaultAsync(c => c.Id == correspondenceId, cancellationToken); } + public sealed record CorrespondenceInboxItemDto( + int Id, + int JobApplicationId, + string? CompanyName, + string? JobTitle, + string From, + string? Direction, + string? Subject, + string? Channel, + DateTime Date, + string ContentPreview, + string? ExternalThreadId, + string? ExternalFrom, + string? ExternalTo, + int LabelCount, + int AttachmentCount); + + [HttpGet] + public async Task>> GetInbox( + [FromQuery] string? q, + [FromQuery] string? direction, + [FromQuery] string? linkState, + CancellationToken cancellationToken) + { + var query = _db.Correspondences + .Include(c => c.JobApplication) + .ThenInclude(j => j.Company) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(q)) + { + var needle = q.Trim(); + query = query.Where(c => + (c.Subject != null && EF.Functions.Like(c.Subject, $"%{needle}%")) || + EF.Functions.Like(c.Content, $"%{needle}%") || + (c.ExternalFrom != null && EF.Functions.Like(c.ExternalFrom, $"%{needle}%")) || + (c.JobApplication.JobTitle != null && EF.Functions.Like(c.JobApplication.JobTitle, $"%{needle}%")) || + (c.JobApplication.Company.Name != null && EF.Functions.Like(c.JobApplication.Company.Name, $"%{needle}%"))); + } + + if (!string.IsNullOrWhiteSpace(direction) && !string.Equals(direction, "all", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(c => c.Direction == direction); + } + + if (string.Equals(linkState, "linked", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(c => c.ExternalThreadId != null); + } + else if (string.Equals(linkState, "manual", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(c => c.ExternalThreadId == null); + } + + var items = await query + .OrderByDescending(c => c.Date) + .Take(200) + .Select(c => new CorrespondenceInboxItemDto( + c.Id, + c.JobApplicationId, + c.JobApplication.Company != null ? c.JobApplication.Company.Name : null, + c.JobApplication.JobTitle, + c.From, + c.Direction, + c.Subject, + c.Channel, + c.Date, + c.Content.Length <= 220 ? c.Content : c.Content.Substring(0, 220), + c.ExternalThreadId, + c.ExternalFrom, + c.ExternalTo, + c.ExternalLabelsJson != null ? 1 : 0, + c.AttachmentMetadataJson != null ? 1 : 0)) + .ToListAsync(cancellationToken); + + return Ok(items); + } + // GET all messages for a job [HttpGet("{jobId:int}")] public async Task>> GetForJob([FromRoute] int jobId, CancellationToken cancellationToken) diff --git a/SMART_GMAIL_PROGRESS.md b/SMART_GMAIL_PROGRESS.md index 694f208..bbb4aec 100644 --- a/SMART_GMAIL_PROGRESS.md +++ b/SMART_GMAIL_PROGRESS.md @@ -9,7 +9,7 @@ - Current focus: M006 / S01 foundation work. ## Completed so far -- Created separate Gmail feature branch. +- Created separate Gmail feature branch and merged the completed checkpoint into `main`. - Captured foundation context in `.gsd/milestones/M006/M006-CONTEXT.md`. - Planned milestones M006-M010 for the Gmail workstream. - Planned slice M006/S01. diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index d1c3692..87d8381 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -225,6 +225,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo } /> } /> } /> + } /> } /> } /> } /> @@ -299,3 +300,7 @@ export default function App() { ); } +mProvider> + + ); +} diff --git a/job-tracker-ui/src/correspondence-inbox-page.test.tsx b/job-tracker-ui/src/correspondence-inbox-page.test.tsx new file mode 100644 index 0000000..534b858 --- /dev/null +++ b/job-tracker-ui/src/correspondence-inbox-page.test.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ToastProvider } from './toast'; +import { I18nProvider } from './i18n/I18nProvider'; +import CorrespondenceInboxPage from './pages/CorrespondenceInboxPage'; +import { api } from './api'; + +jest.mock('./api', () => ({ + api: { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, + getApiErrorMessage: (error: any, fallback?: string) => fallback || 'Request failed.', +})); + +const mockedApi = api as jest.Mocked; + +function renderPage() { + return render( + + + + + + + , + ); +} + +describe('CorrespondenceInboxPage', () => { + beforeEach(() => { + mockedApi.get.mockResolvedValue({ + data: [ + { + id: 1, + jobApplicationId: 42, + companyName: 'Acme Systems', + jobTitle: 'Backend Engineer', + from: 'Company', + direction: 'inbound', + subject: 'Interview invite', + channel: 'Email', + date: new Date().toISOString(), + contentPreview: 'We would like to schedule an interview.', + externalThreadId: 'thread-1', + externalFrom: 'Maria Recruiter ', + externalTo: 'user@example.test', + labelCount: 2, + attachmentCount: 1, + }, + ], + } as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders correspondence inbox items and reloads with filters', async () => { + renderPage(); + + expect(await screen.findByText(/correspondence inbox/i)).toBeInTheDocument(); + expect(await screen.findByText(/1 items/i)).toBeInTheDocument(); + expect(await screen.findByText(/acme systems/i)).toBeInTheDocument(); + expect(await screen.findByText(/backend engineer/i)).toBeInTheDocument(); + expect(screen.getByText(/2 labels/i)).toBeInTheDocument(); + expect(screen.getByText(/1 attachments/i)).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText(/search/i), { target: { value: 'Maria' } }); + fireEvent.mouseDown(screen.getAllByRole('combobox')[0]); + fireEvent.click((await screen.findAllByRole('option', { name: /Inbound/i }))[0]); + fireEvent.click(screen.getByRole('button', { name: /refresh/i })); + + await waitFor(() => { + expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence', expect.objectContaining({ + params: expect.objectContaining({ + q: 'Maria', + direction: 'inbound', + }), + })); + }); + }); +}); diff --git a/job-tracker-ui/src/pages/CorrespondenceInboxPage.tsx b/job-tracker-ui/src/pages/CorrespondenceInboxPage.tsx new file mode 100644 index 0000000..c5821b0 --- /dev/null +++ b/job-tracker-ui/src/pages/CorrespondenceInboxPage.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Box, + Chip, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + TextField, + Typography, + Button, +} from "@mui/material"; +import MailOutlineIcon from "@mui/icons-material/MailOutline"; +import { api, getApiErrorMessage } from "../api"; +import { useToast } from "../toast"; + +export type CorrespondenceInboxItem = { + id: number; + jobApplicationId: number; + companyName?: string | null; + jobTitle?: string | null; + from: string; + direction?: string | null; + subject?: string | null; + channel?: string | null; + date: string; + contentPreview: string; + externalThreadId?: string | null; + externalFrom?: string | null; + externalTo?: string | null; + labelCount: number; + attachmentCount: number; +}; + +export default function CorrespondenceInboxPage() { + const navigate = useNavigate(); + const { toast } = useToast(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [query, setQuery] = useState(""); + const [direction, setDirection] = useState("all"); + const [linkState, setLinkState] = useState("all"); + + const load = async () => { + setLoading(true); + try { + const res = await api.get("/correspondence", { + params: { + q: query.trim() || undefined, + direction: direction === "all" ? undefined : direction, + linkState: linkState === "all" ? undefined : linkState, + }, + }); + setItems(res.data ?? []); + } catch (error) { + toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const filteredSummary = useMemo(() => { + const linked = items.filter((item) => item.externalThreadId).length; + const inbound = items.filter((item) => item.direction === "inbound").length; + return { linked, inbound }; + }, [items]); + + return ( + + + + Correspondence inbox + + Cross-job view of imported correspondence and Gmail-linked history. + + + + } label={`${items.length} items`} variant="outlined" /> + 0 ? "success" : "default"} /> + + + + + + setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" /> + + Direction + + + + Link state + + + + + + {loading ? : null} + + {!loading && items.length === 0 ? ( + No correspondence matches the current filters. + ) : null} + + + {items.map((item) => ( + + + + {item.companyName || "Unknown company"} • {item.jobTitle || "Unknown role"} + {item.subject || item.contentPreview} + + {item.externalFrom || item.from} {item.externalTo ? `→ ${item.externalTo}` : ""} · {new Date(item.date).toLocaleString()} + + + + {item.direction ? : null} + {item.externalThreadId ? : } + {item.labelCount > 0 ? : null} + {item.attachmentCount > 0 ? : null} + + + + + ))} + + + ); +}