fix(mail): search every owned job
CI and Deploy / test (pull_request) Successful in 5m13s
CI and Deploy / deploy (pull_request) Has been skipped

Replace ineffective pageSize=100 selectors with a bounded owner-filtered search endpoint for composing and moving linked threads.
This commit is contained in:
cesnimda
2026-08-15 20:13:14 +02:00
parent dc511296a4
commit a08ba9b45d
9 changed files with 167 additions and 57 deletions
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Autocomplete,
Box,
Button,
Chip,
@@ -43,7 +44,6 @@ import {
GmailStatus,
GmailThreadRefreshResult,
GmailUnlinkResult,
JobApplication,
} from "../types";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
@@ -103,9 +103,7 @@ function formatReasonLabel(label: string) {
}
}
interface PagedResult<T> {
items: T[];
}
type JobChoice = { id: number; jobTitle: string; companyName: string };
export type CorrespondenceJobContext = {
companyName?: string | null;
@@ -153,7 +151,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
const [availableJobs, setAvailableJobs] = useState<JobApplication[]>([]);
const [availableJobs, setAvailableJobs] = useState<JobChoice[]>([]);
const [jobChoiceQuery, setJobChoiceQuery] = useState("");
const [jobChoicesLoading, setJobChoicesLoading] = useState(false);
const [manageThreadId, setManageThreadId] = useState<string | null>(null);
const [manageTargetJobId, setManageTargetJobId] = useState<number>(jobId);
const [manageNote, setManageNote] = useState("");
@@ -195,12 +195,15 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
}
}, [jobId, toast]);
const loadAvailableJobs = useCallback(async () => {
const loadAvailableJobs = useCallback(async (query?: string) => {
try {
const res = await api.get<PagedResult<JobApplication>>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } });
setAvailableJobs((res.data?.items ?? []).filter((item) => item.id !== jobId));
setJobChoicesLoading(true);
const res = await api.get<JobChoice[]>("/jobapplications/choices", { params: { q: query?.trim() || undefined, limit: 50 } });
setAvailableJobs((res.data ?? []).filter((item) => item.id !== jobId));
} catch {
setAvailableJobs([]);
} finally {
setJobChoicesLoading(false);
}
}, [jobId]);
@@ -257,8 +260,13 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
useEffect(() => {
void loadGmailStatus();
void loadAvailableJobs();
}, [loadAvailableJobs, loadGmailStatus]);
}, [loadGmailStatus]);
useEffect(() => {
if (!manageThreadId) return;
const timer = window.setTimeout(() => void loadAvailableJobs(jobChoiceQuery), jobChoiceQuery.trim() ? 250 : 0);
return () => window.clearTimeout(timer);
}, [jobChoiceQuery, loadAvailableJobs, manageThreadId]);
useEffect(() => {
if (!gmailStatus?.connected || linkedThreadIds.length === 0) {
@@ -405,6 +413,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
const openManageThread = (threadId: string) => {
setManageThreadId(threadId);
setManageTargetJobId(jobId);
setJobChoiceQuery("");
setManageNote("");
};
@@ -443,7 +452,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
await loadGmailMatches(gmailQuery);
setManageThreadId(null);
const targetJob = availableJobs.find((item) => item.id === manageTargetJobId);
toast(`Moved thread to ${targetJob?.company?.name || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success");
toast(`Moved thread to ${targetJob?.companyName || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to move the Gmail thread."), "error");
} finally {
@@ -561,21 +570,18 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
minRows={2}
placeholder="Why this thread should stay in review or move to another job."
/>
<FormControl fullWidth>
<InputLabel>Move to job</InputLabel>
<Select
value={String(manageTargetJobId)}
label="Move to job"
onChange={(event) => setManageTargetJobId(Number(event.target.value))}
>
<MenuItem value={String(jobId)}>Keep on current job</MenuItem>
{availableJobs.map((item) => (
<MenuItem key={item.id} value={String(item.id)}>
{item.company?.name || "Unknown company"} {item.jobTitle}
</MenuItem>
))}
</Select>
</FormControl>
<Autocomplete
options={availableJobs}
loading={jobChoicesLoading}
value={availableJobs.find((item) => item.id === manageTargetJobId) ?? null}
onInputChange={(_, value, reason) => {
if (reason === "input" || reason === "clear") setJobChoiceQuery(value);
}}
onChange={(_, value) => setManageTargetJobId(value?.id ?? jobId)}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(item) => `${item.companyName || "Unknown company"}${item.jobTitle}`}
renderInput={(params) => <TextField {...params} label="Move to job" placeholder="Search every job" />}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setManageThreadId(null)} disabled={manageSaving}>Close</Button>
@@ -51,28 +51,20 @@ describe("correspondence Gmail import", () => {
correspondenceMessages = [];
mockedApi.get.mockImplementation((url: string, config?: any) => {
if (url === "/jobapplications") {
if (url === "/jobapplications/choices") {
return Promise.resolve({
data: {
items: [
data: [
{
id: 42,
jobTitle: "Backend Developer",
status: "Applied",
dateApplied: new Date().toISOString(),
daysSince: 3,
company: { name: "Acme", recruiterEmail: "maria@acme.test", recruiterName: "Maria Recruiter" },
companyName: "Acme",
},
{
id: 77,
jobTitle: "Platform Engineer",
status: "Applied",
dateApplied: new Date().toISOString(),
daysSince: 1,
company: { name: "Beta" },
companyName: "Beta",
},
],
},
} as any);
}
if (url === "/jobapplications/42") {
@@ -377,7 +369,7 @@ describe("correspondence Gmail import", () => {
renderDialog();
fireEvent.click(await screen.findByRole("button", { name: /manage thread-1/i }));
fireEvent.mouseDown((await screen.findAllByRole("combobox")).slice(-1)[0]);
fireEvent.mouseDown(await screen.findByRole("combobox", { name: /move to job/i }));
fireEvent.click(await screen.findByRole("option", { name: /beta • platform engineer/i }));
fireEvent.click(screen.getByRole("button", { name: /move thread/i }));
@@ -66,6 +66,7 @@ describe('CorrespondenceInboxPage', () => {
},
] } as any);
if (url === '/email/drafts') return Promise.resolve({ data: [] } as any);
if (url === '/jobapplications/choices') return Promise.resolve({ data: [] } as any);
if (url === '/email/message') return Promise.resolve({ data: {
id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter <maria@acme.test>', to: 'user@example.test',
date: new Date().toISOString(), snippet: 'Interview', bodyText: 'Please choose an interview time.', labels: ['INBOX'], attachments: [{ fileName: 'agenda.pdf' }],
@@ -328,9 +329,9 @@ describe('CorrespondenceInboxPage', () => {
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true },
{ provider: 'microsoft', displayName: 'Outlook', connected: true, address: 'owner@outlook.test', canRead: true, canSend: false },
] } as any);
if (url === '/jobapplications') return Promise.resolve({ data: { items: [
{ id: 42, jobTitle: 'Backend Engineer', company: { name: 'Acme Systems' } },
] } } as any);
if (url === '/jobapplications/choices') return Promise.resolve({ data: [
{ id: 42, jobTitle: 'Backend Engineer', companyName: 'Acme Systems' },
] } as any);
return original!(url, config);
});
mockedApi.post.mockImplementation((url: string) => {
@@ -371,6 +372,34 @@ describe('CorrespondenceInboxPage', () => {
}));
});
test('searches the complete server-side job choice set while composing', async () => {
const original = mockedApi.get.getMockImplementation();
mockedApi.get.mockImplementation((url: string, config?: any) => {
if (url === '/email/providers') return Promise.resolve({ data: [
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true },
] } as any);
if (url === '/jobapplications/choices') {
return Promise.resolve({ data: config?.params?.q === 'Historic'
? [{ id: 7, jobTitle: 'Historic target role', companyName: 'Archive Co' }]
: [{ id: 42, jobTitle: 'Recent role', companyName: 'Acme Systems' }] } as any);
}
return original!(url, config);
});
renderPage();
const compose = await screen.findByRole('button', { name: /compose new email/i });
await waitFor(() => expect(compose).toBeEnabled());
fireEvent.click(compose);
fireEvent.change(screen.getByRole('combobox', { name: /^job$/i }), { target: { value: 'Historic' } });
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', {
params: { q: 'Historic', limit: 20 },
}));
fireEvent.click(await screen.findByRole('option', { name: /Archive Co.*Historic target role/i }));
fireEvent.click(screen.getByRole('button', { name: /start draft/i }));
expect(screen.getByText(/Archive Co.*Historic target role/i)).toBeInTheDocument();
});
test('does not offer retry when delivery is uncertain', async () => {
const original = mockedApi.get.getMockImplementation();
mockedApi.get.mockImplementation((url: string, config?: any) => {
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from "react-router-dom";
import {
Box,
Alert,
Autocomplete,
Chip,
CircularProgress,
FormControl,
@@ -53,7 +54,7 @@ type EmailProviderStatus = {
type JobChoice = {
id: number;
jobTitle: string;
company?: { name?: string | null } | null;
companyName: string;
};
export function emailProviderStatusLabel(provider: EmailProviderStatus) {
@@ -119,6 +120,8 @@ export default function CorrespondenceInboxPage() {
const [providerStatusLoaded, setProviderStatusLoaded] = useState(false);
const [jobs, setJobs] = useState<JobChoice[]>([]);
const [jobsLoaded, setJobsLoaded] = useState(false);
const [jobsLoading, setJobsLoading] = useState(false);
const [jobQuery, setJobQuery] = useState("");
const [composeSetupOpen, setComposeSetupOpen] = useState(false);
const [composeJobId, setComposeJobId] = useState<number | "">("");
const [composeProvider, setComposeProvider] = useState("");
@@ -178,11 +181,18 @@ export default function CorrespondenceInboxPage() {
}, []);
useEffect(() => {
api.get<{ items?: JobChoice[] }>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } })
.then((response) => setJobs(Array.isArray(response.data?.items) ? response.data.items : []))
.catch(() => setJobs([]))
.finally(() => setJobsLoaded(true));
}, []);
const timer = window.setTimeout(() => {
setJobsLoading(true);
api.get<JobChoice[]>("/jobapplications/choices", { params: { q: jobQuery.trim() || undefined, limit: 20 } })
.then((response) => setJobs(Array.isArray(response.data) ? response.data : []))
.catch(() => setJobs([]))
.finally(() => {
setJobsLoaded(true);
setJobsLoading(false);
});
}, jobQuery.trim() ? 250 : 0);
return () => window.clearTimeout(timer);
}, [jobQuery]);
const loadStoredDrafts = useCallback(async () => {
try {
@@ -312,7 +322,7 @@ export default function CorrespondenceInboxPage() {
}))) return;
setDraft({
jobApplicationId: job.id,
companyName: job.company?.name || "Unknown company",
companyName: job.companyName || "Unknown company",
jobTitle: job.jobTitle || "Unknown role",
provider: provider.provider,
providerName: provider.displayName,
@@ -558,12 +568,18 @@ export default function CorrespondenceInboxPage() {
<Paper component="section" aria-labelledby="compose-new-title" variant="outlined" sx={{ p: 2, borderRadius: 3, mb: 2 }}>
<Typography id="compose-new-title" component="h2" variant="h6" sx={{ fontWeight: 900, mb: 1.5 }}>Compose new email</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr auto" }, gap: 1.25, alignItems: "center" }}>
<FormControl fullWidth>
<InputLabel id="compose-job-label">Job</InputLabel>
<Select labelId="compose-job-label" value={composeJobId} label="Job" onChange={(event) => setComposeJobId(Number(event.target.value))}>
{jobs.map((job) => <MenuItem key={job.id} value={job.id}>{job.company?.name || "Unknown company"} · {job.jobTitle}</MenuItem>)}
</Select>
</FormControl>
<Autocomplete
options={jobs}
loading={jobsLoading}
value={jobs.find((job) => job.id === composeJobId) ?? null}
onInputChange={(_, value, reason) => {
if (reason === "input" || reason === "clear") setJobQuery(value);
}}
onChange={(_, value) => setComposeJobId(value?.id ?? "")}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(job) => `${job.companyName || "Unknown company"} · ${job.jobTitle}`}
renderInput={(params) => <TextField {...params} label="Job" placeholder="Search every job" />}
/>
<FormControl fullWidth>
<InputLabel id="compose-provider-label">Sending provider</InputLabel>
<Select labelId="compose-provider-label" value={composeProvider} label="Sending provider" onChange={(event) => setComposeProvider(String(event.target.value))}>