feat: add correspondence inbox and gmail ingestion contract

This commit is contained in:
2026-04-01 16:50:14 +02:00
parent 289c2f47ad
commit 3f04849fe6
5 changed files with 317 additions and 1 deletions
+5
View File
@@ -225,6 +225,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/reminders" element={<RemindersView />} />
<Route path="/kanban" element={<KanbanBoard />} />
<Route path="/companies" element={<CompaniesTable />} />
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/admin/audit" element={<AdminAuditPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
@@ -299,3 +300,7 @@ export default function App() {
</ToastProvider>
);
}
mProvider>
</ToastProvider>
);
}
@@ -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<typeof api>;
function renderPage() {
return render(
<ToastProvider>
<I18nProvider>
<MemoryRouter>
<CorrespondenceInboxPage />
</MemoryRouter>
</I18nProvider>
</ToastProvider>,
);
}
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 <maria@acme.test>',
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',
}),
}));
});
});
});
@@ -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<CorrespondenceInboxItem[]>([]);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const [direction, setDirection] = useState<string>("all");
const [linkState, setLinkState] = useState<string>("all");
const load = async () => {
setLoading(true);
try {
const res = await api.get<CorrespondenceInboxItem[]>("/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 (
<Paper sx={{ mt: 0, p: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>Correspondence inbox</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Cross-job view of imported correspondence and Gmail-linked history.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" />
<Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} />
<Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" />
</Box>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
<TextField label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />
<FormControl fullWidth>
<InputLabel>Direction</InputLabel>
<Select value={direction} label="Direction" onChange={(e) => setDirection(String(e.target.value))}>
<MenuItem value="all">All</MenuItem>
<MenuItem value="inbound">Inbound</MenuItem>
<MenuItem value="outbound">Outbound</MenuItem>
<MenuItem value="internal">Internal</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Link state</InputLabel>
<Select value={linkState} label="Link state" onChange={(e) => setLinkState(String(e.target.value))}>
<MenuItem value="all">All</MenuItem>
<MenuItem value="linked">Linked threads</MenuItem>
<MenuItem value="manual">Manual/internal only</MenuItem>
</Select>
</FormControl>
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button>
</Box>
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
{!loading && items.length === 0 ? (
<Typography sx={{ color: "text.secondary", py: 4, textAlign: "center" }}>No correspondence matches the current filters.</Typography>
) : null}
<Stack spacing={1.25}>
{items.map((item) => (
<Paper key={item.id} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{item.companyName || "Unknown company"} {item.jobTitle || "Unknown role"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{item.subject || item.contentPreview}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
{item.externalFrom || item.from} {item.externalTo ? `${item.externalTo}` : ""} · {new Date(item.date).toLocaleString()}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
{item.direction ? <Chip size="small" label={item.direction} variant="outlined" /> : null}
{item.externalThreadId ? <Chip size="small" label={`Thread ${item.externalThreadId}`} color="success" variant="outlined" /> : <Chip size="small" label="Manual/internal" variant="outlined" />}
{item.labelCount > 0 ? <Chip size="small" label={`${item.labelCount} labels`} variant="outlined" /> : null}
{item.attachmentCount > 0 ? <Chip size="small" label={`${item.attachmentCount} attachments`} variant="outlined" /> : null}
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${item.jobApplicationId}`)}>Open job</Button>
</Box>
</Box>
</Paper>
))}
</Stack>
</Paper>
);
}