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
@@ -74,6 +74,45 @@ public sealed class JobApplicationsAuthorizationTests
Assert.IsType<NotFoundResult>(result.Result);
}
[Fact]
public async Task Job_choices_searches_all_owned_rows_without_leaking_another_tenant()
{
var dbName = Guid.NewGuid().ToString();
await using (var ownerDb = CreateDb(dbName, "owner-1"))
{
var company = new Company { Name = "Owner company", OwnerUserId = "owner-1" };
ownerDb.Companies.Add(company);
for (var index = 0; index < 130; index++)
{
ownerDb.JobApplications.Add(new JobApplication
{
JobTitle = index == 0 ? "Historic target role" : $"Recent role {index}",
Company = company,
OwnerUserId = "owner-1",
SavedAt = DateTime.UtcNow.AddDays(index),
});
}
await ownerDb.SaveChangesAsync();
}
await using (var otherDb = CreateDb(dbName, "owner-2"))
{
var company = new Company { Name = "Other tenant", OwnerUserId = "owner-2" };
otherDb.JobApplications.Add(new JobApplication
{
JobTitle = "Historic target private", Company = company, OwnerUserId = "owner-2",
});
await otherDb.SaveChangesAsync();
}
await using var searchDb = CreateDb(dbName, "owner-1");
var result = await CreateController(searchDb).GetChoices("historic target", 20, default);
var choices = Assert.IsType<List<JobApplicationChoiceDto>>(Assert.IsType<OkObjectResult>(result.Result).Value);
var choice = Assert.Single(choices);
Assert.Equal("Historic target role", choice.JobTitle);
Assert.Equal("Owner company", choice.CompanyName);
}
private static JobTrackerContext CreateDb(string dbName, string? userId)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
@@ -15,6 +15,8 @@ using static JobTrackerApi.Services.JobApplicationHelpers;
namespace JobTrackerApi.Controllers
{
public sealed record JobApplicationChoiceDto(int Id, string JobTitle, string CompanyName);
[ApiController]
// Explicitly authorized. These endpoints are all tenant-scoped user data, so they must not
// depend on the Auth:Require fallback policy being switched on: a deployment that lost that flag
@@ -2268,6 +2270,31 @@ Job description:
});
}
[HttpGet("choices")]
public async Task<ActionResult<List<JobApplicationChoiceDto>>> GetChoices(
[FromQuery] string? q = null,
[FromQuery] int limit = 20,
CancellationToken cancellationToken = default)
{
limit = Math.Clamp(limit, 1, 50);
var query = _db.JobApplications.AsNoTracking()
.Where(item => !item.IsDeleted);
if (!string.IsNullOrWhiteSpace(q))
{
var like = $"%{q.Trim()}%";
query = query.Where(item =>
EF.Functions.Like(item.JobTitle, like) ||
EF.Functions.Like(item.Company.Name, like));
}
return Ok(await query
.OrderByDescending(item => item.DateApplied ?? item.SavedAt)
.ThenByDescending(item => item.Id)
.Select(item => new JobApplicationChoiceDto(item.Id, item.JobTitle, item.Company.Name))
.Take(limit)
.ToListAsync(cancellationToken));
}
[HttpGet("ai-metrics")]
[HttpGet("summarizer-metrics")]
public async Task<ActionResult<AiServiceMetrics>> GetSummarizerMetrics(CancellationToken cancellationToken)
+1
View File
@@ -214,3 +214,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-180 | CV operation/store focused real-SQLite tests and full backend | Repository root | Keep dormant CV extraction history consistent with cancellation, deadline recovery and retry before worker claim | PASS — focused 17/17 and backend 660/660. Cancel sets the run terminal immediately, retry reopens it, deadline recovery fails it, and owner/task/subject predicates prevent unrelated updates | Synthetic rows only; no parser/model/MariaDB/production process interruption | AI-004 dormant-row consistency gap closed |
| V-181 | AI usage meter/operation/workspace/export/deletion real-SQLite tests; EF model check; SQLite/MariaDB scripts; disposable SQLite backfill and fresh application startup; full backend | Repository root | Make Workspace and durable Strategy/CV usage owner-safe, idempotent and independent of deletable private history | PASS — focused 28/28 and backend 663/663; no pending model changes; both providers generate bounded additive DDL; SQLite backfills the synthetic legacy row exactly once; fresh runtime applies through `20260815175236_AddCrossFeatureAiUsage` and serves `/health` | Synthetic local rows only; no provider/model call, MariaDB server, production migration or worker activation. CV retains a conservative reservation and older synchronous AI paths are not yet universal | Main durable usage boundary implemented; remaining synchronous producers stay tracked under POL-001 |
| V-182 | Real ASP.NET Identity data-protection token integration on SQLite; focused auth tests; full backend | Repository root | Close SEC-005B expiry/replay/custom-username proof without SMTP or production | PASS — valid confirmation succeeds once, replay and zero-lifetime expiry return the same generic failure, a real change-email token preserves a custom username and cannot replay; focused 39/39 and backend 666/666 | Synthetic addresses and ephemeral local data-protection keys only; no email, browser, MariaDB or production call | SEC-005B local token-state gap closed |
| V-183 | Owner-filtered job-choice API test; correspondence Jest; frontend production build | Repository root / `job-tracker-ui` | Remove the email compose/thread-move selectors' false 100-job ceiling | PASS — backend search finds the oldest target among 130 owned rows and excludes another tenant; correspondence 20/20 proves debounced server search, compose selection and thread-move selection; TypeScript/production build passes | Synthetic rows/JSDOM only; no provider, email or production action | MAIL-001 exhaustive job selection gap closed |
+1 -1
View File
@@ -179,7 +179,7 @@ Status: `IN PROGRESS`. Canonical hub routing, provider-neutral reads, explicit-c
- Compose new email loads the authenticated user's recent job list and offers only connected providers that report send consent. Read-only/disconnected providers are absent from the selector and the send API still rechecks connection capability.
- The job and provider selectors have explicit accessible labels. Missing jobs and missing send consent produce visible guidance instead of a non-explanatory disabled path.
- Starting creates a blank local threadless draft; Save draft and Review and send reuse the same bounded persistence, UUID, revision conflict, confirmation and delivery paths as replies.
- Focused inbox passes 13/13, full frontend 198/198 and production build pass. The hub selection is currently bounded to 100 recent owned jobs; this is recorded rather than presented as exhaustive.
- The compose and linked-thread move selectors use an owner-filtered server search rather than pretending a large page size is exhaustive. Search evaluates all owned, non-deleted applications and returns bounded compact choices. Focused correspondence passes 20/20, the owner/isolation endpoint slice passes 4/4, and the production build passes.
## Remaining MAIL-001 work
+1 -1
View File
@@ -668,7 +668,7 @@ This queue records the highest-value work that can proceed without production cr
- **Blocker:** real provider/re-consent, full SEC-009 deletion, MariaDB, production and required 375/768/1440/theme/keyboard browser gates are unavailable or require new authority.
- **Evidence:** `docs/verification/mail-001-job-email-hub.md`; V-126V-153. Draft/new-message UI 13/13, API/idempotency/rotation 10/10, persistence 1/1 with dual-provider reversible SQL and readable export 4/4; Free send policy 7/7; provider states 9/9; hub unlink 8/8 UI and 2/2 API; shared application context focused 10/10; prior send export/cascade focused 16/16; recovery/send focused 10/10; legacy follow-up/worker 10/10; delivery/capability 18/18; provider/correspondence 5/5; hub detail 5/5; backend 630/630; frontend 50/50 suites and 198/198 tests plus build/audit; local empty/disconnected and compatibility-route browser smoke at 1280×720.
- **Commit:** `6008b4a` (hub), `536d403` (neutral reads), `a20775c` (safe detail), `653f011` (ledger), `e9937ac` (Gmail/Graph delivery adapters and consent), `123fc55` (explicit-confirmed send API), `449faeb` (confirmed reply composer), `ee5ef7e` (interrupted-send recovery), `8fe3903` (legacy SMTP retirement), `aff34cc` (content-free export and cascade evidence), `ff547df` (shared application context), `1dabbeb` (confirmed hub unlink), `f9e641c` (honest provider states), `7f41cb2` (Free email policy regression), `14b396a` (inert tenant draft persistence), `2fa4e38` (owner-isolated readable draft export), `a9bb22e` (tenant-safe revisioned draft API), `80b5532` (persisted draft send identity), `d3d2b67` (saved reply recovery/conflicts), `29de263` (definitive-failure identity rotation), `b735963` (new-message job/provider drafting).
- **Remaining work:** full account deletion remains SEC-009; provider mailbox category capabilities require separately authorized scopes/re-consent and remain absent; searchable selection beyond the 100 recent jobs; browser/production verification. Existing connections need explicit re-consent; IMAP remains read-only. No real email; uncertain sends need manual reconciliation. JT-019 blocks a clean full-chain SQLite rehearsal before the new draft migration.
- **Remaining work:** full account deletion remains SEC-009; provider mailbox category capabilities require separately authorized scopes/re-consent and remain absent; browser/production verification. Server-side owner-filtered job choice search now reaches applications beyond the old first-page ceiling (V-183). Existing connections need explicit re-consent; IMAP remains read-only. No real email; uncertain sends need manual reconciliation. JT-019 blocks a clean full-chain SQLite rehearsal before the new draft migration.
### JOBS-001 — Job-search source and assessment redesign
@@ -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 : []))
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));
}, []);
.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))}>