feat: add correspondence inbox and gmail ingestion contract
This commit is contained in:
@@ -25,6 +25,84 @@ namespace JobTrackerApi.Controllers
|
|||||||
.FirstOrDefaultAsync(c => c.Id == correspondenceId, cancellationToken);
|
.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<ActionResult<List<CorrespondenceInboxItemDto>>> 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
|
// GET all messages for a job
|
||||||
[HttpGet("{jobId:int}")]
|
[HttpGet("{jobId:int}")]
|
||||||
public async Task<ActionResult<List<Correspondence>>> GetForJob([FromRoute] int jobId, CancellationToken cancellationToken)
|
public async Task<ActionResult<List<Correspondence>>> GetForJob([FromRoute] int jobId, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- Current focus: M006 / S01 foundation work.
|
- Current focus: M006 / S01 foundation work.
|
||||||
|
|
||||||
## Completed so far
|
## 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`.
|
- Captured foundation context in `.gsd/milestones/M006/M006-CONTEXT.md`.
|
||||||
- Planned milestones M006-M010 for the Gmail workstream.
|
- Planned milestones M006-M010 for the Gmail workstream.
|
||||||
- Planned slice M006/S01.
|
- Planned slice M006/S01.
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
|||||||
<Route path="/reminders" element={<RemindersView />} />
|
<Route path="/reminders" element={<RemindersView />} />
|
||||||
<Route path="/kanban" element={<KanbanBoard />} />
|
<Route path="/kanban" element={<KanbanBoard />} />
|
||||||
<Route path="/companies" element={<CompaniesTable />} />
|
<Route path="/companies" element={<CompaniesTable />} />
|
||||||
|
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
<Route path="/admin/audit" element={<AdminAuditPage />} />
|
<Route path="/admin/audit" element={<AdminAuditPage />} />
|
||||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
@@ -299,3 +300,7 @@ export default function App() {
|
|||||||
</ToastProvider>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user