From a08ba9b45d992fff880ac4ce5d3a5118269e0592 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 15 Aug 2026 20:13:14 +0200 Subject: [PATCH] fix(mail): search every owned job Replace ineffective pageSize=100 selectors with a bounded owner-filtered search endpoint for composing and moving linked threads. --- .../JobApplicationsAuthorizationTests.cs | 39 +++++++++++++ .../Controllers/JobApplicationsController.cs | 27 +++++++++ docs/audits/verification-log.md | 1 + docs/verification/mail-001-job-email-hub.md | 2 +- docs/work-programmes/master-work-plan.md | 2 +- .../src/components/Correspondence.tsx | 58 ++++++++++--------- .../src/correspondence-gmail-import.test.tsx | 18 ++---- .../src/correspondence-inbox-page.test.tsx | 35 ++++++++++- .../src/views/CorrespondenceInboxPage.tsx | 42 +++++++++----- 9 files changed, 167 insertions(+), 57 deletions(-) diff --git a/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs b/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs index 5d2c21c..d0010e9 100644 --- a/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs @@ -74,6 +74,45 @@ public sealed class JobApplicationsAuthorizationTests Assert.IsType(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>(Assert.IsType(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() diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index db4edfb..d52233a 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -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>> 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> GetSummarizerMetrics(CancellationToken cancellationToken) diff --git a/docs/audits/verification-log.md b/docs/audits/verification-log.md index 534f083..59527e8 100644 --- a/docs/audits/verification-log.md +++ b/docs/audits/verification-log.md @@ -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 | diff --git a/docs/verification/mail-001-job-email-hub.md b/docs/verification/mail-001-job-email-hub.md index a635dde..cc466b0 100644 --- a/docs/verification/mail-001-job-email-hub.md +++ b/docs/verification/mail-001-job-email-hub.md @@ -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 diff --git a/docs/work-programmes/master-work-plan.md b/docs/work-programmes/master-work-plan.md index 3e75fc6..01eca3f 100644 --- a/docs/work-programmes/master-work-plan.md +++ b/docs/work-programmes/master-work-plan.md @@ -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-126–V-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 diff --git a/job-tracker-ui/src/components/Correspondence.tsx b/job-tracker-ui/src/components/Correspondence.tsx index bdb33af..1c0d201 100644 --- a/job-tracker-ui/src/components/Correspondence.tsx +++ b/job-tracker-ui/src/components/Correspondence.tsx @@ -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 { - 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(null); const [importingThreadId, setImportingThreadId] = useState(null); - const [availableJobs, setAvailableJobs] = useState([]); + const [availableJobs, setAvailableJobs] = useState([]); + const [jobChoiceQuery, setJobChoiceQuery] = useState(""); + const [jobChoicesLoading, setJobChoicesLoading] = useState(false); const [manageThreadId, setManageThreadId] = useState(null); const [manageTargetJobId, setManageTargetJobId] = useState(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>("/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("/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." /> - - Move to job - - + 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) => } + /> diff --git a/job-tracker-ui/src/correspondence-gmail-import.test.tsx b/job-tracker-ui/src/correspondence-gmail-import.test.tsx index 3b3f1a2..f1e4f4d 100644 --- a/job-tracker-ui/src/correspondence-gmail-import.test.tsx +++ b/job-tracker-ui/src/correspondence-gmail-import.test.tsx @@ -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 })); diff --git a/job-tracker-ui/src/correspondence-inbox-page.test.tsx b/job-tracker-ui/src/correspondence-inbox-page.test.tsx index d319b57..3cfe329 100644 --- a/job-tracker-ui/src/correspondence-inbox-page.test.tsx +++ b/job-tracker-ui/src/correspondence-inbox-page.test.tsx @@ -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 ', 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) => { diff --git a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx index 24e4fe9..ac46633 100644 --- a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx +++ b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx @@ -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([]); const [jobsLoaded, setJobsLoaded] = useState(false); + const [jobsLoading, setJobsLoading] = useState(false); + const [jobQuery, setJobQuery] = useState(""); const [composeSetupOpen, setComposeSetupOpen] = useState(false); const [composeJobId, setComposeJobId] = useState(""); 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("/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() { Compose new email - - 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) => } + /> Sending provider