From 27fd70a2d7c7549d624d9f8d920582d85d0c1909 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 11 Apr 2026 01:34:32 +0200 Subject: [PATCH] refactor, security updates, cv extraction upgrades --- .gitignore | 3 + .gsd/DECISIONS.md | 3 + .gsd/OVERRIDES.md | 8 + .gsd/event-log.jsonl | 43 + .gsd/state-manifest.json | 2562 ++++++++++++++++- .../AttachmentsControllerTests.cs | 9 +- .../AuthAndSystemControllerTests.cs | 13 +- JobTrackerApi.Tests/BackupControllerTests.cs | 18 + .../ClientErrorsControllerTests.cs | 103 + JobTrackerApi.Tests/CvCorpusHarnessTests.cs | 57 +- .../ProfileCvControllerTests.cs | 301 +- .../Controllers/AttachmentsController.cs | 4 +- JobTrackerApi/Controllers/AuthController.cs | 139 +- JobTrackerApi/Controllers/BackupController.cs | 2 + .../Controllers/ClientErrorsController.cs | 74 +- JobTrackerApi/Controllers/ExportController.cs | 2 + .../Controllers/ProfileCvController.cs | 742 ++++- JobTrackerApi/Controllers/UsersController.cs | 3 + JobTrackerApi/Program.cs | 1057 +------ JobTrackerApi/Services/AuthSessionOptions.cs | 78 + JobTrackerApi/Services/CvAiNormalizer.cs | 58 + JobTrackerApi/Services/CvProcessingQueue.cs | 71 + .../Services/DailyExportHostedService.cs | 6 +- .../Services/FollowUpReminderHostedService.cs | 5 +- .../Services/JobEnrichmentHostedService.cs | 5 +- JobTrackerApi/Services/RulesHostedService.cs | 5 +- .../StartupInitializationExtensions.cs | 1004 +++++++ JobTrackerApi/Services/StartupReadiness.cs | 27 + JobTrackerApi/Services/SummarizerService.cs | 50 +- JobTrackerApi/appsettings.Development.json | 1 + Models/StructuredCvProfileJson.cs | 128 +- job-tracker-ui/package-lock.json | 24 +- job-tracker-ui/package.json | 2 +- job-tracker-ui/src/App.tsx | 52 +- job-tracker-ui/src/api.ts | 22 +- job-tracker-ui/src/auth.ts | 94 +- .../src/components/AuthStatusCard.tsx | 33 +- .../src/components/CompaniesTable.tsx | 171 +- .../src/components/DashboardView.tsx | 98 +- .../src/components/GoogleAuthCard.tsx | 69 +- job-tracker-ui/src/components/JobTable.tsx | 52 +- job-tracker-ui/src/components/KanbanBoard.tsx | 203 +- .../src/components/RemindersView.tsx | 90 +- .../src/components/UserManagementCard.tsx | 18 +- .../src/components/ViewStateNotice.tsx | 45 + .../src/daily-control-loop.test.tsx | 4 +- job-tracker-ui/src/hooks/useCompanies.ts | 49 +- job-tracker-ui/src/hooks/useViewResource.ts | 99 + job-tracker-ui/src/login-page.test.tsx | 10 +- job-tracker-ui/src/pages/LoginPage.tsx | 8 +- job-tracker-ui/src/pages/ProfilePage.tsx | 63 +- job-tracker-ui/src/themePrefs.ts | 21 +- scripts/compare-cv-approved-fixtures.py | 171 ++ scripts/export-cv-corpus-json.sh | 91 + scripts/prepare-cv-approved-fixtures.py | 134 + tools/summarizer/.dockerignore | 10 + tools/summarizer/README.md | 2 +- tools/summarizer/app.py | 112 +- tools/summarizer/tests/test_app.py | 50 +- 59 files changed, 6817 insertions(+), 1561 deletions(-) create mode 100644 JobTrackerApi.Tests/ClientErrorsControllerTests.cs create mode 100644 JobTrackerApi/Services/AuthSessionOptions.cs create mode 100644 JobTrackerApi/Services/CvAiNormalizer.cs create mode 100644 JobTrackerApi/Services/CvProcessingQueue.cs create mode 100644 JobTrackerApi/Services/StartupInitializationExtensions.cs create mode 100644 JobTrackerApi/Services/StartupReadiness.cs create mode 100644 job-tracker-ui/src/components/ViewStateNotice.tsx create mode 100644 job-tracker-ui/src/hooks/useViewResource.ts create mode 100644 scripts/compare-cv-approved-fixtures.py create mode 100755 scripts/export-cv-corpus-json.sh create mode 100644 scripts/prepare-cv-approved-fixtures.py create mode 100644 tools/summarizer/.dockerignore diff --git a/.gitignore b/.gitignore index 32979a5..6d8c7e0 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ target/ .gsd/reports/ .gsd/milestones/**/continue.md .gsd/milestones/**/*-CONTINUE.md + +# ── GSD baseline (auto-generated) ── +.gsd-id diff --git a/.gsd/DECISIONS.md b/.gsd/DECISIONS.md index 48d09e0..6f1e9f4 100644 --- a/.gsd/DECISIONS.md +++ b/.gsd/DECISIONS.md @@ -24,3 +24,6 @@ | D016 | M001/S07 | uat-artifact | How S07 daily-loop closure should capture acceptance evidence | Keep docs/s06-acceptance-run.md as the canonical execution log and use S07 closure artifacts to summarize/import the cross-surface proof rather than duplicating raw runner output. | S07's job is to prove one seeded job stays coherent across /jobs, workspace, /reminders, and /dashboard while preserving the manual-send boundary. Reusing the S06 runner output as the canonical source keeps reruns idempotent, prevents drift between generated logs and human summary text, and gives downstream slices one stable place for detailed evidence plus one concise dependency summary. | Yes | agent | | D017 | M005 planning | delivery | How M005 execution should be staged and published | Execute M005 one slice at a time, verify each slice independently, push each slice on its own git branch, then continue to the next slice only after the prior slice is stable. | The CV intelligence/export milestone is high-risk and multi-layered. Slice-by-slice branching and push discipline will keep extraction, tailored draft, and PDF rendering changes reviewable and reduce regression blast radius. | Yes | human | | D018 | M005 planning | verification | What document corpus should drive universal CV extraction verification | Use the real CV files placed in /home/pi/cvs as a regression corpus for universal extractor work, alongside synthetic/unit fixtures. | A universal CV extractor cannot be validated only against synthetic fixtures. Real CVs with different layouts, OCR quality, and structure are required to test extraction, review UX, and rendering assumptions. | Yes | human | +| D019 | M011/S01 | frontend-platform | How to handle frontend build-tool risk during the initial platform hardening slice | Remediate the direct critical frontend dependency immediately, keep the CRA baseline for the next hardening slice, and defer the broader frontend build-tool migration to a later dedicated implementation step. | The audit showed one critical direct dependency issue (`axios`) and a large remaining body of transitive risk concentrated behind `react-scripts`. Upgrading the direct dependency removed the critical finding with low change surface, restored a reproducible local and Docker build baseline, and avoids coupling S02 auth/session work to a framework migration. The remaining CRA transitive debt is still real, but it is now a contained follow-on migration concern rather than an immediate blocker. | Yes | agent | +| D020 | M011/S02 | authentication | What session transport should replace browser-stored bearer tokens in the frontend and API | Use an HttpOnly cookie-backed app session for the primary local auth path, have the API read the local app JWT from a secure cookie instead of browser storage, keep Google credential exchange server-side, and add CSRF protection for state-changing requests. | The current design stores the app bearer token in localStorage/sessionStorage and attaches it via an Authorization header on every request, which leaves the primary local auth path exposed to XSS-driven token theft. A cookie-backed session keeps the app token out of browser storage, lets the API enforce the local auth path centrally, preserves existing JWT-based authorization semantics on the server, and gives the frontend a cleaner source of truth through `/auth/me` and explicit unauthorized responses. Adding CSRF protection alongside the cookie keeps state-changing requests safe under the new transport. | Yes | agent | +| D021 | M011/S03/T01 | frontend-architecture | How to centralize degraded-state handling for the core frontend views in S03. | Use a lightweight shared frontend async-view-state pattern for S03 instead of introducing a new global data-fetching framework in this slice. | The current risk is not lack of a full query library; it is that core views swallow request failures into empty arrays or nulls and then render normal empty states. A small shared abstraction for loading/empty/error/retry state can retire that product risk quickly across the highest-traffic views without broadening S03 into a framework migration or destabilizing the existing app. | Yes | agent | diff --git a/.gsd/OVERRIDES.md b/.gsd/OVERRIDES.md index 762b782..8289ea6 100644 --- a/.gsd/OVERRIDES.md +++ b/.gsd/OVERRIDES.md @@ -10,3 +10,11 @@ User-issued overrides that supersede plan document content. **Applied-at:** M001/S01/T01 --- + +## Override: 2026-04-10T16:46:22.130Z + +**Change:** use next.js +**Scope:** active +**Applied-at:** M001/none/none + +--- diff --git a/.gsd/event-log.jsonl b/.gsd/event-log.jsonl index 13ecdfa..1e77415 100644 --- a/.gsd/event-log.jsonl +++ b/.gsd/event-log.jsonl @@ -13,3 +13,46 @@ {"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S03"},"ts":"2026-03-28T22:05:32.786Z","actor":"agent","hash":"ae2f80720d601a48","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"} {"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S04"},"ts":"2026-03-28T22:05:48.342Z","actor":"agent","hash":"38e10b5bfc9e49e6","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"} {"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S05"},"ts":"2026-03-28T22:06:02.267Z","actor":"agent","hash":"a4cdfef1b0f97af3","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"} +{"cmd":"plan-milestone","params":{"milestoneId":"M006"},"ts":"2026-04-01T13:42:13.507Z","actor":"agent","hash":"4e6e2177aea2c247","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"} +{"cmd":"plan-milestone","params":{"milestoneId":"M007"},"ts":"2026-04-01T13:45:43.599Z","actor":"agent","hash":"f74c11f87b160d5e","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"} +{"cmd":"plan-milestone","params":{"milestoneId":"M010"},"ts":"2026-04-01T13:45:43.608Z","actor":"agent","hash":"0767a15a4163e364","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"} +{"cmd":"plan-milestone","params":{"milestoneId":"M009"},"ts":"2026-04-01T13:45:43.609Z","actor":"agent","hash":"868651dc3e9840ba","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"} +{"cmd":"plan-milestone","params":{"milestoneId":"M008"},"ts":"2026-04-01T13:45:43.611Z","actor":"agent","hash":"a17e013ae4c6fbc7","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"} +{"cmd":"plan-slice","params":{"milestoneId":"M006","sliceId":"S01"},"ts":"2026-04-01T13:46:55.228Z","actor":"agent","hash":"53e13651ee21608e","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"} +{"v":2,"cmd":"plan-milestone","params":{"milestoneId":"M011"},"ts":"2026-04-10T16:33:49.574Z","actor":"agent","hash":"8da5bd1f6d8be219","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S01"},"ts":"2026-04-10T16:36:01.325Z","actor":"agent","hash":"1b39eb81745f79cb","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S01","taskId":"T01"},"ts":"2026-04-10T16:45:13.023Z","actor":"agent","hash":"df43e89bf0ef508a","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S01","taskId":"T02"},"ts":"2026-04-10T16:46:52.982Z","actor":"agent","hash":"fc183a287cf7e0ec","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S01","taskId":"T03"},"ts":"2026-04-10T16:47:07.060Z","actor":"agent","hash":"96dbf0b722260441","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S01"},"ts":"2026-04-10T16:47:38.406Z","actor":"agent","hash":"e8b7e8fcc07292af","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S01"},"ts":"2026-04-10T16:47:48.162Z","actor":"agent","hash":"e8d28553a74cd045","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S02"},"ts":"2026-04-10T16:48:16.316Z","actor":"agent","hash":"c6f7c425cd77c100","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S02","taskId":"T01"},"ts":"2026-04-10T16:49:40.607Z","actor":"agent","hash":"1e247d4737f232b4","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S02","taskId":"T02"},"ts":"2026-04-10T19:57:16.264Z","actor":"agent","hash":"02eb6bc1686244e9","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S02","taskId":"T03"},"ts":"2026-04-10T19:57:41.031Z","actor":"agent","hash":"85c32d040f9631aa","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S02"},"ts":"2026-04-10T19:58:17.389Z","actor":"agent","hash":"3115b597816bc8cb","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S02"},"ts":"2026-04-10T19:58:21.945Z","actor":"agent","hash":"51ed90ab022e6ae9","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S03"},"ts":"2026-04-10T22:04:32.223Z","actor":"agent","hash":"10a79a238ead7007","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S03","taskId":"T01"},"ts":"2026-04-10T22:05:25.953Z","actor":"agent","hash":"4d7f978e674fb278","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S03","taskId":"T02"},"ts":"2026-04-10T22:19:14.274Z","actor":"agent","hash":"94e0f7a9b24dd246","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S03","taskId":"T03"},"ts":"2026-04-10T22:19:33.234Z","actor":"agent","hash":"31c5bb74a3280df0","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S03"},"ts":"2026-04-10T22:20:05.975Z","actor":"agent","hash":"7a76f48b67c6a4fa","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S03"},"ts":"2026-04-10T22:20:18.782Z","actor":"agent","hash":"0bdf677f91c94f7b","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S04"},"ts":"2026-04-10T22:32:19.950Z","actor":"agent","hash":"ad5a195d23e3979e","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S04","taskId":"T01"},"ts":"2026-04-10T22:33:06.567Z","actor":"agent","hash":"dff04d446600fb9c","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S04","taskId":"T02"},"ts":"2026-04-10T22:44:02.977Z","actor":"agent","hash":"0a94bd5f4e0d3c90","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S04","taskId":"T03"},"ts":"2026-04-10T22:44:23.671Z","actor":"agent","hash":"6148706a46d32f7b","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S04"},"ts":"2026-04-10T22:44:54.430Z","actor":"agent","hash":"e68c20060f20dd34","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S04"},"ts":"2026-04-10T22:45:07.836Z","actor":"agent","hash":"f92571f10029d5e9","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S05"},"ts":"2026-04-10T22:55:56.643Z","actor":"agent","hash":"39e7d7ed3cc34612","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S05","taskId":"T01"},"ts":"2026-04-10T22:56:09.946Z","actor":"agent","hash":"c133fd6bf6b26629","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S05","taskId":"T02"},"ts":"2026-04-10T22:59:48.954Z","actor":"agent","hash":"f603df2d0e5cd772","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S05","taskId":"T03"},"ts":"2026-04-10T23:00:30.352Z","actor":"agent","hash":"96ecf88ce819d73b","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S05"},"ts":"2026-04-10T23:00:57.810Z","actor":"agent","hash":"31a2aca44265f192","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S05"},"ts":"2026-04-10T23:01:02.519Z","actor":"agent","hash":"fe0bd7ec6ab8df21","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S06"},"ts":"2026-04-10T23:01:49.394Z","actor":"agent","hash":"f2b438884ca52230","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S06","taskId":"T01"},"ts":"2026-04-10T23:20:01.968Z","actor":"agent","hash":"406e0f3c172d1161","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S06","taskId":"T02"},"ts":"2026-04-10T23:24:03.823Z","actor":"agent","hash":"1a2544dcd9f4f925","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S06","taskId":"T03"},"ts":"2026-04-10T23:24:23.101Z","actor":"agent","hash":"f583516649531d4c","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S06"},"ts":"2026-04-10T23:24:52.479Z","actor":"agent","hash":"b2c2dc564fb09dfe","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} +{"v":2,"cmd":"complete-milestone","params":{"milestoneId":"M011"},"ts":"2026-04-10T23:25:36.547Z","actor":"agent","hash":"10b42cbd47fe0d4a","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"} diff --git a/.gsd/state-manifest.json b/.gsd/state-manifest.json index 6ce2028..341ea53 100644 --- a/.gsd/state-manifest.json +++ b/.gsd/state-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "exported_at": "2026-03-28T22:06:02.266Z", + "exported_at": "2026-04-10T23:25:36.543Z", "milestones": [ { "id": "M001", @@ -153,6 +153,315 @@ ], "requirement_coverage": "This milestone extends R003 by improving the quality and controllability of tailored CV output, remains constrained by R008/R015 because generation/export stay manual, and stays aligned with R009/R016 because the flow optimizes for one person's personal job search materials rather than recruiter/team workflows. It also surfaces a likely new requirement around deterministic document export from job-tailored drafts, to be formalized when execution starts.", "boundary_map_markdown": "### Boundary map\n- **Canonical profile layer**: raw upload artifact, extracted text, normalized text, extraction metadata, provenance/confidence, and user-reviewed structured CV.\n- **Tailored draft layer**: job-specific editable CV draft derived from canonical profile + job context; separate from master profile and separate from final export artifact.\n- **Render/export layer**: deterministic HTML/CSS template rendering, preview generation, PDF output, and render options.\n- **Current profile compatibility layer**: existing `ProfileCvText` and `ProfileCvStructureJson` remain compatible during rollout.\n- **Not in scope**: autonomous application submission, autonomous outbound communication, recruiter/team collaboration features, replacing external CV design tools wholesale.\n- **External dependencies**: existing summarizer/OCR service, browser/PDF rendering runtime, local/remote file storage for artifacts.\n" + }, + { + "id": "M006", + "title": "", + "status": "queued", + "depends_on": [], + "created_at": "2026-04-01T13:38:20.257Z", + "completed_at": null, + "vision": "Turn the existing job-local Gmail import helpers into a real integration foundation: durable OAuth connection, sync state, normalized Gmail ingestion contract, and the first platform seams required for cross-job correspondence workflows.", + "success_criteria": [ + "Gmail connection state, token lifecycle, and sync state are visible and durable for one user account.", + "A normalized Gmail ingestion/storage contract exists for messages, threads, labels, and attachment metadata, without breaking existing per-job correspondence import flows.", + "The codebase has explicit service boundaries that allow later milestones to add cross-job matching, review queues, and Phase 2 AI enrichment without rewriting the foundation." + ], + "key_risks": [ + { + "risk": "Existing Gmail logic is job-local and may be too coupled to the per-job correspondence dialog.", + "whyItMatters": "If the ingestion/model layer stays job-scoped, later confidence routing and global inbox work will become a rewrite instead of an extension." + }, + { + "risk": "OAuth/token handling may work for connect/import but not for repeatable sync lifecycle and recovery.", + "whyItMatters": "Phase 1 must be stable even when Gmail is disconnected, tokens expire, or sync partially fails." + } + ], + "proof_strategy": [ + { + "riskOrUnknown": "Can Gmail foundation be extracted from the current job-local import flow without regressions?", + "retireIn": "M006/S01-S02", + "whatWillBeProven": "Connection, token, and normalized ingestion seams work while existing GmailController behavior remains testable." + }, + { + "riskOrUnknown": "Will the data contract support later review queues and unmatched-thread workflows?", + "retireIn": "M006/S02-S03", + "whatWillBeProven": "Stored metadata and sync-state models can drive global correspondence and matching milestones without schema churn." + } + ], + "verification_contract": "Focused backend tests for Gmail service/controller seams plus focused frontend tests for connection/sync-state surfaces must pass.", + "verification_integration": "Existing per-job correspondence Gmail flows must remain functional while new shared ingestion/state seams are introduced.", + "verification_operational": "Admin/system visibility for Gmail/OAuth/sync status should remain diagnosable and non-destructive.", + "verification_uat": "A local user can connect Gmail (or see clear not-configured state), inspect sync status, and exercise the foundation UI without breaking the existing workspace.", + "definition_of_done": [ + "OAuth and sync-state foundations are in place and documented.", + "Existing Gmail per-job flows still pass focused regression tests.", + "Foundation code is ready for cross-job ingestion/linking milestones without hidden rewrites." + ], + "requirement_coverage": "Advances R001, R002, R007, and R010 as the platform base for broader correspondence integration while preserving R008/R015 manual-send boundaries.", + "boundary_map_markdown": "- **OAuth / token boundary:** Gmail account connection, token storage, refresh, and disconnect lifecycle.\n- **Ingestion boundary:** raw Gmail API responses normalized into internal message/thread/attachment metadata.\n- **Job-linking boundary:** deferred to later milestones; M006 should not hardwire ingestion to a single job UI.\n- **AI boundary:** Phase 2 interfaces prepared only; no hard dependency introduced in foundation." + }, + { + "id": "M007", + "title": "", + "status": "queued", + "depends_on": [], + "created_at": "2026-04-01T13:42:29.599Z", + "completed_at": null, + "vision": "Phase 2 of the feature sequence: convert the Gmail foundation into durable ingestion with manual sync, historical backfill, stable deduplication, and stored attachment metadata.", + "success_criteria": [ + "Manual Gmail sync works for a useful historical window and excludes spam/trash by default.", + "Imported emails are deduplicated by Gmail message id.", + "Stored records preserve message/thread metadata and attachment metadata needed by later milestones." + ], + "key_risks": [ + { + "risk": "Backfill and dedup can create noisy duplicates or partial state when the same Gmail messages appear through multiple queries.", + "whyItMatters": "Phase 1 trust depends on stable ingestion more than fancy matching." + }, + { + "risk": "Attachment handling may overcomplicate ingestion too early.", + "whyItMatters": "Phase 1 needs metadata support, not a heavyweight document-processing rewrite." + } + ], + "proof_strategy": [ + { + "riskOrUnknown": "Can manual sync/backfill ingest useful history without duplicate churn?", + "retireIn": "M007/S01-S02", + "whatWillBeProven": "Message-id dedup and repeatable sync state survive reruns." + }, + { + "riskOrUnknown": "Can attachment metadata be captured cheaply enough for Phase 1?", + "retireIn": "M007/S02", + "whatWillBeProven": "Attachment names/types/sizes are stored and surfaced without deep attachment parsing." + } + ], + "verification_contract": "Focused backend tests for sync, dedup, and attachment metadata must pass.", + "verification_integration": "M006 foundation remains intact while sync/backfill and dedup are layered on.", + "verification_operational": "Sync runs produce visible counts and failure state.", + "verification_uat": "A manual sync/backfill can be run twice without duplicate churn.", + "definition_of_done": [ + "Manual sync and historical backfill work against the shared ingestion seam.", + "Deduplication by Gmail message id is enforced and tested.", + "Attachment metadata is stored without breaking existing correspondence flows." + ], + "requirement_coverage": "Advances R001, R002, R007, and R010 by making Gmail correspondence a durable imported history rather than one-off imports.", + "boundary_map_markdown": "- **Sync boundary:** manual sync/backfill orchestrates Gmail fetches into normalized storage.\n- **Dedup boundary:** Gmail message id is the canonical duplicate key; thread grouping is secondary.\n- **Attachment boundary:** store metadata now, defer heavy attachment content extraction until explicitly needed." + }, + { + "id": "M008", + "title": "Smart Gmail Job Correspondence Integration — matching and routing", + "status": "pending", + "depends_on": [], + "created_at": "2026-04-01T13:45:43.577Z", + "completed_at": null, + "vision": "Phase 3 of the feature sequence: route Gmail correspondence across jobs with deterministic scoring, explicit confidence handling, and safe suggestion workflows.", + "success_criteria": [ + "Deterministic matching scores Gmail correspondence across jobs using normalized recruiter/company/role/thread signals.", + "High-confidence matches auto-link, medium-confidence matches queue for review, and low-confidence items remain unmatched.", + "Job-related unmatched threads can become suggested new jobs without silently mutating tracked jobs." + ], + "key_risks": [ + { + "risk": "Cross-job matching can become opaque if scoring reasons are not durable.", + "whyItMatters": "The user must be able to trust why a thread auto-linked or entered review." + }, + { + "risk": "Auto-linking can create false positives if normalization is weak.", + "whyItMatters": "Phase 1 value depends on confidence routing, not just broad import volume." + } + ], + "proof_strategy": [ + { + "riskOrUnknown": "Can deterministic scoring route matches safely without AI?", + "retireIn": "M008/S01-S02", + "whatWillBeProven": "High/medium/low confidence thresholds produce reviewable behavior with stored reasons." + }, + { + "riskOrUnknown": "Can unmatched but job-related Gmail threads become useful suggestions?", + "retireIn": "M008/S03", + "whatWillBeProven": "Suggestion flow surfaces likely job threads without silently creating bad data." + } + ], + "verification_contract": "Focused backend tests for scoring, routing, and suggestion logic must pass.", + "verification_integration": "Linked items must appear as correspondence on the correct job without status drift.", + "verification_operational": "Stored reasons and confidence must make routing diagnosable.", + "verification_uat": "A user can review medium-confidence matches and accept or reject them with clear reasons.", + "definition_of_done": [ + "Confidence-based Gmail-to-job linking exists with explicit routing.", + "High-confidence matches auto-link and medium-confidence items queue for review.", + "Unmatched job-related threads can surface as suggestions without overriding core job status." + ], + "requirement_coverage": "Advances R001, R002, R007, and R010 by linking Gmail correspondence across jobs with explicit confidence routing.", + "boundary_map_markdown": "- **Matching boundary:** deterministic rules and normalization score messages/threads against jobs.\n- **Routing boundary:** high/medium/low confidence routes must remain explicit and reviewable.\n- **Suggestion boundary:** unmatched job-related threads can become suggested new jobs without silently creating canonical jobs." + }, + { + "id": "M009", + "title": "Smart Gmail Job Correspondence Integration — correspondence UX", + "status": "pending", + "depends_on": [], + "created_at": "2026-04-01T13:45:43.578Z", + "completed_at": null, + "vision": "Phase 4 of the feature sequence: make Gmail correspondence part of the daily workflow through a global inbox, per-job timelines, and manual review controls.", + "success_criteria": [ + "A global correspondence inbox exists for linked, review, and unmatched Gmail items.", + "Per-job conversation timelines show Gmail-backed correspondence coherently.", + "Users can review, relink, unlink, add notes, and filter correspondence without hidden state changes." + ], + "key_risks": [ + { + "risk": "Inbox and review UX can become noisy if all confidence states are mixed together.", + "whyItMatters": "Phase 1 needs triage clarity, not just more screens." + }, + { + "risk": "Relink/unlink can break trust if actions are irreversible or poorly explained.", + "whyItMatters": "Users need manual control over correspondence history." + } + ], + "proof_strategy": [ + { + "riskOrUnknown": "Can a global inbox stay useful rather than overwhelming?", + "retireIn": "M009/S01", + "whatWillBeProven": "Inbox grouping/filtering keeps review and unmatched flows manageable." + }, + { + "riskOrUnknown": "Can per-job timelines and review actions remain coherent?", + "retireIn": "M009/S02-S03", + "whatWillBeProven": "Relink/unlink/notes/filter flows preserve correspondence history and user control." + } + ], + "verification_contract": "Focused frontend and integration tests for inbox/timeline/review flows must pass.", + "verification_integration": "Global inbox and per-job views must stay consistent after review actions.", + "verification_operational": "Review decisions and sync freshness must be visible.", + "verification_uat": "A user can triage correspondence globally and inspect the same linked thread inside a job timeline.", + "definition_of_done": [ + "A global correspondence inbox exists.", + "Per-job timeline and review flows are coherent and reversible.", + "Filters and notes make the correspondence surfaces practically useful." + ], + "requirement_coverage": "Advances R005, R006, R007, and R010 by turning imported Gmail correspondence into a daily-use triage and timeline surface.", + "boundary_map_markdown": "- **Inbox boundary:** global correspondence inbox summarizes imported/linked/review items across jobs.\n- **Job timeline boundary:** per-job conversation timeline shows linked Gmail correspondence cleanly beside existing notes/imported messages.\n- **Review boundary:** relink/unlink/notes/filters remain manual and auditable." + }, + { + "id": "M010", + "title": "Smart Gmail Job Correspondence Integration — Phase 2 preparation", + "status": "pending", + "depends_on": [], + "created_at": "2026-04-01T13:45:43.578Z", + "completed_at": null, + "vision": "Phase 5 of the feature sequence: prepare clean extension points and documentation for future LLM-assisted correspondence intelligence without overbuilding or making Phase 1 dependent on AI.", + "success_criteria": [ + "Future semantic matching and extraction/classification seams are prepared in code without over-implementing AI behavior.", + "Phase 1 remains fully valuable when Ollama/AI is disabled.", + "Phase 2 documentation clearly describes safe additions for semantic matching, entity extraction, stage hints, interview extraction, and follow-up/reply suggestion generation." + ], + "key_risks": [ + { + "risk": "Phase 2 prep can overbuild abstractions before real needs stabilize.", + "whyItMatters": "Preparation should reduce risk, not add speculative complexity." + }, + { + "risk": "Future AI enrichment could bypass deterministic evidence and reduce trust.", + "whyItMatters": "Phase 1 must remain valuable and understandable without AI." + } + ], + "proof_strategy": [ + { + "riskOrUnknown": "Can future AI seams be prepared without hard-coding premature behavior?", + "retireIn": "M010/S01", + "whatWillBeProven": "Interfaces/storage hooks exist without forcing live AI code into Phase 1." + }, + { + "riskOrUnknown": "Will future contributors understand the intended semantic-matching roadmap?", + "retireIn": "M010/S02", + "whatWillBeProven": "Phase 2 docs clearly explain semantic matching, extraction, stage hints, and follow-up suggestion directions." + } + ], + "verification_contract": "Focused tests ensure no Phase 1 behavior regresses when Phase 2 seams are present.", + "verification_integration": "Phase 2 seams do not change Phase 1 runtime behavior unless an implementation is explicitly wired in later.", + "verification_operational": "Docs and code make future enrichment attach evidence instead of silently mutating tracked state.", + "verification_uat": "No dedicated UAT beyond confirming Phase 1 behavior remains unchanged while docs/interfaces exist.", + "definition_of_done": [ + "Phase 2 interfaces and data seams are documented and lightly scaffolded.", + "No Phase 1 feature depends on AI being enabled.", + "Future semantic matching/classification work has a safe extension path." + ], + "requirement_coverage": "Prepares deferred correspondence/AI requirements while preserving Phase 1 independence and the manual-control constraints R008/R015.", + "boundary_map_markdown": "- **LLM seam boundary:** future semantic disambiguation lives behind explicit interfaces/services.\n- **Extraction boundary:** future AI-derived hints attach to stored correspondence/match evidence instead of replacing deterministic truth.\n- **Suggestion boundary:** future drafting/classification remains optional and non-autonomous." + }, + { + "id": "M011", + "title": "Platform hardening across frontend, API, AI service, and Ollama", + "status": "complete", + "depends_on": [], + "created_at": "2026-04-10T16:32:03.760Z", + "completed_at": "2026-04-10T23:25:36.520Z", + "vision": "Retire the highest-risk security, reliability, UX, and operability gaps across the full stack without losing the existing product surface. The outcome should be a safer frontend platform, stronger auth/session handling, clearer degraded-state behavior, a slimmer and more maintainable API startup path, and an AI/Ollama integration that is observable, bounded, and explicit about its capabilities.", + "success_criteria": [ + "The application has a materially safer security posture across frontend, API, and AI integrations.", + "Core user workflows remain functional while degraded states become explicit and actionable.", + "Startup, auth, and AI runtime behavior are easier to operate, test, and debug.", + "The platform is positioned for continued feature work without carrying today’s highest-risk technical debt." + ], + "key_risks": [ + { + "risk": "Frontend build modernization can break existing CRA-based tests and deployment assumptions.", + "whyItMatters": "Security remediation is tied to outdated build tooling, so the migration path has to preserve behavior while reducing risk." + }, + { + "risk": "Auth migration from localStorage/sessionStorage to cookies can break login, Gmail, and admin flows if done piecemeal.", + "whyItMatters": "Session changes affect every protected route and must be rolled out with clear compatibility and verification coverage." + }, + { + "risk": "`Program.cs` refactoring may destabilize startup, migrations, or local/dev bootstrap behavior.", + "whyItMatters": "The current app concentrates operational logic in startup; untangling it safely requires preserving current environment behavior." + }, + { + "risk": "AI service changes can reduce perceived product quality if fallback behavior becomes slower or less capable.", + "whyItMatters": "The app now relies on OCR, summarization, and Ollama-assisted enrichment in key package-generation flows." + } + ], + "proof_strategy": [ + { + "riskOrUnknown": "Whether the frontend can reduce current dependency/security debt without breaking builds", + "retireIn": "S01", + "whatWillBeProven": "A passing frontend build/test path on the new dependency baseline, plus a reduced audit report." + }, + { + "riskOrUnknown": "Whether secure session storage can replace browser-stored bearer tokens without breaking user flows", + "retireIn": "S02", + "whatWillBeProven": "Login/logout/profile/admin flows operating on cookie-based auth with explicit CSRF/session behavior." + }, + { + "riskOrUnknown": "Whether degraded backend/API states can be made obvious without harming normal UX", + "retireIn": "S03", + "whatWillBeProven": "Browser-level evidence showing clear empty/error/offline distinctions and centralized client data handling." + }, + { + "riskOrUnknown": "Whether backend hardening can land without regressions to startup, file handling, and admin functions", + "retireIn": "S04-S05", + "whatWillBeProven": "Passing API tests, startup validation, and explicit security/operability checks for headers, throttling, and uploads." + }, + { + "riskOrUnknown": "Whether the AI/Ollama stack can be simplified and made predictable without losing required capabilities", + "retireIn": "S06", + "whatWillBeProven": "Measured AI-service contracts, documented fallback modes, and verified admin/runtime visibility of capability status." + } + ], + "verification_contract": "Each slice must produce concrete verification at the layer it changes: frontend build/tests/browser assertions, API diagnostics/tests, startup/runtime checks, dependency audit deltas, and AI-service health/behavior evidence.", + "verification_integration": "Cross-slice verification must include browser-backed checks of login/session behavior, degraded API handling, admin/system telemetry, and at least one AI-assisted workflow path.", + "verification_operational": "Operational verification must prove startup safety, header/rate-limit posture, session/auth stability, and AI capability reporting in the admin/system surface.", + "verification_uat": "UAT focuses on the user-visible outcomes: clear auth behavior, no misleading empty states during outages, stable job/workspace flows, and understandable AI capability/degraded-state feedback.", + "definition_of_done": [ + "Frontend dependency risk materially reduced, with the critical direct vulnerability removed and build tooling direction settled.", + "Authentication/session handling no longer depends on browser-stored bearer tokens for the primary local auth path.", + "The UI distinguishes empty data from backend/API failure and presents actionable degraded-state guidance.", + "API startup/bootstrap responsibilities are separated enough to be testable and safer to deploy.", + "Core security controls are in place: rate limiting, stricter headers, production-safe CORS posture, and safer logging/file handling.", + "AI/Ollama behavior is contractually clear, observable, and degraded modes are explicit in both runtime metrics and UI." + ], + "requirement_coverage": "Advances security, reliability, operability, and UX quality requirements across the whole stack: frontend shell, API, Gmail integrations, admin tools, AI service, and Ollama-backed features.", + "boundary_map_markdown": "- **Frontend shell (`job-tracker-ui/`)** owns navigation, auth UX, degraded states, data fetching, and admin surfaces.\n- **API (`JobTrackerApi/`, `Data/`, `Models/`)** owns auth/session issuance, authorization, rate limiting, startup/bootstrap, file handling, Gmail integration, and system telemetry.\n- **AI service (`tools/summarizer/`)** owns OCR, summarization, CV normalization/classification, and Ollama reachability contracts.\n- **Infrastructure (`docker-compose.yml`, Dockerfiles, nginx.conf`)** owns proxy/security headers, service topology, and deployment/runtime defaults.\n- **Cross-cutting concerns**: security posture, observability, performance budgets, and failure-mode UX must be verified slice-by-slice end to end." } ], "slices": [ @@ -420,6 +729,405 @@ "observability_impact": "Adds template/render-option visibility to preview/export diagnostics.", "sequence": 0, "replan_triggered_at": null + }, + { + "milestone_id": "M006", + "id": "S01", + "title": "Gmail account and sync foundation", + "status": "pending", + "risk": "high", + "depends": [], + "demo": "After this slice, Gmail connection/sync state is explicit and durable instead of being hidden inside the current correspondence tab.", + "created_at": "2026-04-01T13:42:13.489Z", + "completed_at": null, + "full_summary_md": "", + "full_uat_md": "", + "goal": "Harden Gmail account connection, token lifecycle, and visible sync-state tracking for one local user.", + "success_criteria": "Connection/disconnect/status flows are explicit, durable, and covered by focused tests.", + "proof_level": "Focused backend + frontend proof.", + "integration_closure": "Existing correspondence Gmail connect/import entry points still work against the refactored foundation.", + "observability_impact": "Add durable Gmail sync/connection state surfaces and actionable error reporting.", + "sequence": 0, + "replan_triggered_at": null + }, + { + "milestone_id": "M006", + "id": "S02", + "title": "Normalized Gmail ingestion contract", + "status": "pending", + "risk": "high", + "depends": [ + "S01" + ], + "demo": "After this slice, Gmail messages/threads/labels/attachment metadata have an explicit shared contract rather than ad-hoc per-job import details.", + "created_at": "2026-04-01T13:42:13.489Z", + "completed_at": null, + "full_summary_md": "", + "full_uat_md": "", + "goal": "Introduce normalized Gmail ingestion/storage models and services that later milestones can reuse for cross-job workflows.", + "success_criteria": "A shared ingestion contract exists and is covered by focused tests without breaking current message/thread import behavior.", + "proof_level": "Backend contract proof.", + "integration_closure": "Current GmailController import/refresh behavior is preserved on top of the new service/model seam.", + "observability_impact": "Expose ingestion counts, dedup signals, and failure reasons.", + "sequence": 0, + "replan_triggered_at": null + }, + { + "milestone_id": "M006", + "id": "S03", + "title": "Phase 2 extension seam", + "status": "pending", + "risk": "medium", + "depends": [ + "S01", + "S02" + ], + "demo": "After this slice, the codebase has explicit interfaces for future AI-assisted matching/classification without any forced AI dependency in Phase 1 foundation.", + "created_at": "2026-04-01T13:42:13.489Z", + "completed_at": null, + "full_summary_md": "", + "full_uat_md": "", + "goal": "Define low-risk extension points for future semantic matching, extraction, and stage hinting.", + "success_criteria": "Phase 2 interfaces and docs exist, and the foundation remains fully useful with deterministic-only logic.", + "proof_level": "Code + docs proof.", + "integration_closure": "No current user-facing behavior depends on the future AI seam.", + "observability_impact": "Document and surface where future enrichment reasons/confidence can attach.", + "sequence": 0, + "replan_triggered_at": null + }, + { + "milestone_id": "M007", + "id": "S01", + "title": "Manual sync and backfill", + "status": "pending", + "risk": "high", + "depends": [], + "demo": "After this slice, a user can manually sync Gmail history into the normalized store with a bounded backfill window.", + "created_at": "2026-04-01T13:45:43.577Z", + "completed_at": null, + "full_summary_md": "", + "full_uat_md": "", + "goal": "Implement manual sync orchestration and backfill window handling.", + "success_criteria": "Manual sync/backfill runs and records counts plus sync state.", + "proof_level": "Backend integration proof.", + "integration_closure": "Uses the M006 foundation without changing matching semantics yet.", + "observability_impact": "Expose sync start/end state, counts, and last-success timestamps.", + "sequence": 0, + "replan_triggered_at": null + }, + { + "milestone_id": "M007", + "id": "S02", + "title": "Deduplication and attachment metadata", + "status": "pending", + "risk": "high", + "depends": [ + "S01" + ], + "demo": "After this slice, rerunning sync does not create duplicates and attachment metadata is visible for imported Gmail messages.", + "created_at": "2026-04-01T13:45:43.577Z", + "completed_at": null, + "full_summary_md": "", + "full_uat_md": "", + "goal": "Enforce message-id deduplication and store attachment metadata.", + "success_criteria": "Dedup and attachment metadata are covered by tests and visible in the data contract.", + "proof_level": "Backend + focused UI proof.", + "integration_closure": "Imported Gmail messages can be safely re-seen across reruns and query overlap.", + "observability_impact": "Add duplicate-skipped counts and attachment metadata surfaces.", + "sequence": 0, + "replan_triggered_at": null + }, + { + "milestone_id": "M008", + "id": "S01", + "title": "Deterministic matching engine", + "status": "pending", + "risk": "high", + "depends": [], + "demo": "After this slice, imported Gmail messages/threads can be scored against all relevant jobs with durable reasons.", + "created_at": "2026-04-01T13:45:43.577Z", + "completed_at": null, + "full_summary_md": "", + "full_uat_md": "", + "goal": "Build deterministic cross-job scoring and normalization for Gmail-to-job matching.", + "success_criteria": "Scoring and normalization are deterministic, explainable, and tested.", + "proof_level": "Backend logic proof.", + "integration_closure": "Consumes M006/M007 seams without introducing AI dependency.", + "observability_impact": "Persist reasons, confidence, and normalization hits for every routing decision.", + "sequence": 0, + "replan_triggered_at": null + }, + { + "milestone_id": "M008", + "id": "S02", + "title": "Confidence routing and review queue", + "status": "pending", + "risk": "high", + "depends": [ + "S01" + ], + "demo": "After this slice, high-confidence matches auto-link and medium-confidence items enter an explicit review queue.", + "created_at": "2026-04-01T13:45:43.577Z", + "completed_at": null, + "full_summary_md": "", + "full_uat_md": "", + "goal": "Implement confidence routing and reviewable linking outcomes.", + "success_criteria": "High/medium/low routing is visible, tested, and reversible.", + "proof_level": "Backend + focused UI proof.", + "integration_closure": "Linked correspondence lands in the right job without overriding primary job state.", + "observability_impact": "Expose auto-linked, queued, and unmatched counts plus reasons.", + "sequence": 0, + "replan_triggered_at": null + }, + { + "milestone_id": "M008", + "id": "S03", + "title": "Suggested new jobs from unmatched threads", + "status": "pending", + "risk": "medium", + "depends": [ + "S01", + "S02" + ], + "demo": "After this slice, unmatched job-like Gmail threads appear as suggested new jobs instead of being dropped.", + "created_at": "2026-04-01T13:45:43.577Z", + "completed_at": null, + "full_summary_md": "", + "full_uat_md": "", + "goal": "Create a suggestion flow for unmatched job-related threads.", + "success_criteria": "Unmatched job-related Gmail threads can be reviewed as job suggestions.", + "proof_level": "Workflow proof.", + "integration_closure": "Suggestions remain separate from canonical jobs until user action.", + "observability_impact": "Track suggestion reasons and conversion decisions.", + "sequence": 0, + "replan_triggered_at": null + }, + { + "milestone_id": "M009", + "id": "S01", + "title": "Global correspondence inbox", + "status": "pending", + "risk": "high", + "depends": [], + "demo": "After this slice, a user can triage correspondence globally instead of only inside one job dialog.", + "created_at": "2026-04-01T13:45:43.578Z", + "completed_at": null, + "full_summary_md": "", + "full_uat_md": "", + "goal": "Build the global correspondence inbox with confidence-aware filtering.", + "success_criteria": "Global inbox exists with useful filters for linked/review/unmatched correspondence.", + "proof_level": "Frontend workflow proof.", + "integration_closure": "Consumes linked/review/suggestion states from prior milestones.", + "observability_impact": "Expose inbox counts by state and sync freshness.", + "sequence": 0, + "replan_triggered_at": null + }, + { + "milestone_id": "M009", + "id": "S02", + "title": "Per-job conversation timeline", + "status": "pending", + "risk": "medium", + "depends": [ + "S01" + ], + "demo": "After this slice, each job shows a cleaner Gmail-backed conversation timeline rather than isolated imported items.", + "created_at": "2026-04-01T13:45:43.578Z", + "completed_at": null, + "full_summary_md": "", + "full_uat_md": "", + "goal": "Enhance per-job timeline with Gmail thread continuity and linking context.", + "success_criteria": "Per-job correspondence timeline cleanly reflects linked Gmail history.", + "proof_level": "Integration proof.", + "integration_closure": "Per-job correspondence remains the detailed workspace for one job.", + "observability_impact": "Show thread/group metadata and link state in the timeline.", + "sequence": 0, + "replan_triggered_at": null + }, + { + "milestone_id": "M009", + "id": "S03", + "title": "Review flow and manual controls", + "status": "pending", + "risk": "medium", + "depends": [ + "S01", + "S02" + ], + "demo": "After this slice, users can review, relink, unlink, and annotate correspondence with confidence.", + "created_at": "2026-04-01T13:45:43.578Z", + "completed_at": null, + "full_summary_md": "", + "full_uat_md": "", + "goal": "Implement review actions, notes, and practical correspondence filters.", + "success_criteria": "Review flow is reversible, filterable, and covered by focused tests.", + "proof_level": "User workflow proof.", + "integration_closure": "Manual review actions update inbox and per-job views consistently.", + "observability_impact": "Track review decisions and relink/unlink actions.", + "sequence": 0, + "replan_triggered_at": null + }, + { + "milestone_id": "M010", + "id": "S01", + "title": "LLM-assisted extension interfaces", + "status": "pending", + "risk": "medium", + "depends": [], + "demo": "After this slice, the codebase has explicit interfaces for future semantic matching and extraction enrichment.", + "created_at": "2026-04-01T13:45:43.578Z", + "completed_at": null, + "full_summary_md": "", + "full_uat_md": "", + "goal": "Scaffold low-risk Phase 2 extension points in code and storage models.", + "success_criteria": "Future semantic matching/extraction interfaces exist without adding live Phase 2 dependency.", + "proof_level": "Code seam proof.", + "integration_closure": "Phase 1 runs exactly as before when no AI enrichment implementation is registered.", + "observability_impact": "Future enrichment reasons/confidence can be attached alongside deterministic evidence.", + "sequence": 0, + "replan_triggered_at": null + }, + { + "milestone_id": "M010", + "id": "S02", + "title": "Phase 2 design documentation", + "status": "pending", + "risk": "low", + "depends": [ + "S01" + ], + "demo": "After this slice, contributors can continue with Phase 2 from docs instead of rediscovery.", + "created_at": "2026-04-01T13:45:43.578Z", + "completed_at": null, + "full_summary_md": "", + "full_uat_md": "", + "goal": "Document Phase 2 design for semantic matching, extraction, stage/status hints, interview extraction, and drafting suggestions.", + "success_criteria": "Phase 2 roadmap/docs exist and clearly separate safe future AI work from Phase 1 behavior.", + "proof_level": "Documentation proof.", + "integration_closure": "Documentation maps future AI features onto the Phase 1 data and service seams.", + "observability_impact": "Docs define how future enrichment reasons/confidence should be stored and surfaced.", + "sequence": 0, + "replan_triggered_at": null + }, + { + "milestone_id": "M011", + "id": "S01", + "title": "S01", + "status": "complete", + "risk": "high", + "depends": [], + "demo": "The frontend builds on a safer dependency baseline, the critical audit issue is retired, and the deployment/build path is documented and reproducible.", + "created_at": "2026-04-10T16:33:49.541Z", + "completed_at": "2026-04-10T16:47:38.386Z", + "full_summary_md": "---\nid: S01\nparent: M011\nmilestone: M011\nprovides:\n - A stable frontend baseline for S02 auth/session hardening\n - A documented proof point that the remaining frontend audit debt is primarily CRA/react-scripts transitive debt\n - A container-compatible frontend lockfile and build path\nrequires:\n []\naffects:\n - S02\n - S03\nkey_files:\n - job-tracker-ui/package.json\n - job-tracker-ui/package-lock.json\nkey_decisions:\n - D019 — remediate the direct critical frontend dependency immediately, keep the CRA baseline stable for the next slice, and defer broader build-tool migration work.\npatterns_established:\n - When frontend Docker uses a different Node/npm major version than the workstation, regenerate lockfiles with the container toolchain before trusting `npm ci` reproducibility.\n - Treat root-owned generated frontend build artifacts as environment contamination; clean them before drawing conclusions from local build failures.\n - Use the smallest safe dependency remediation first when the audit shows a single direct critical issue and the rest of the debt is trapped behind legacy build tooling.\nobservability_surfaces:\n - Frontend dependency audit before/after evidence\n - Reproducible local build verification\n - Reproducible container `npm ci` verification\n - Successful `docker compose build frontend` evidence\ndrill_down_paths:\n - .gsd/milestones/M011/slices/S01/tasks/T01-SUMMARY.md\n - .gsd/milestones/M011/slices/S01/tasks/T02-SUMMARY.md\n - .gsd/milestones/M011/slices/S01/tasks/T03-SUMMARY.md\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-04-10T16:47:38.387Z\nblocker_discovered: false\n---\n\n# S01: Frontend dependency and build modernization baseline\n\n**Retired the direct critical frontend dependency issue and restored a reproducible frontend build baseline for local and Docker workflows.**\n\n## What Happened\n\nThis slice established the real frontend dependency/build baseline, retired the direct critical audit finding, and restored a reproducible build path for both the local workstation and the frontend Docker image. The direct `axios` dependency was upgraded from `^1.13.6` to `^1.15.0`, which removed the only critical finding reported by the frontend audit. During verification, the frontend build initially failed because the checked-out `job-tracker-ui/build/static` directory contained root-owned artifacts from an earlier containerized build. I repaired that workspace contamination and then found a second environment issue: the lockfile generated under the local Node 25/npm 11 toolchain was not accepted by the Node 20/npm 10 toolchain used in the frontend Dockerfile. Regenerating the lockfile with the container toolchain restored `npm ci` and `docker compose build frontend` reproducibility. The resulting baseline is stable enough to support S02 auth/session work without compounding change risk. The remaining frontend audit debt is now clearly attributable to the older CRA/react-scripts build chain rather than a direct high-risk application dependency.\n\n## Verification\n\nVerified by rerunning the frontend audit, local build, container `npm ci`, and Docker image build after upgrading axios and regenerating the lockfile with the Node 20/npm 10 container toolchain. The full frontend test suite was also run to measure regression risk; 16 suites passed and 2 pre-existing workflow/package suites remain for follow-up.\n\n## Requirements Advanced\n\n- Frontend platform hardening baseline established and direct dependency risk reduced. — \n\n## Requirements Validated\n\nNone.\n\n## New Requirements Surfaced\n\n- Need a durable policy for lockfile generation when local and container npm versions differ.\n- Need targeted follow-up for pre-existing workflow/package UI test drift.\n\n## Requirements Invalidated or Re-scoped\n\nNone.\n\n## Deviations\n\nThe slice completed with a smaller code change than originally estimated because the immediate critical risk was isolated to a direct dependency upgrade. The main unexpected work was build-environment repair: root-owned frontend build artifacts and an npm-version-specific lockfile mismatch between the workstation and the Docker image.\n\n## Known Limitations\n\nThis slice did not remove the remaining transitive audit findings tied to `react-scripts`; it established a stable baseline and retired the direct critical issue. Full frontend audit cleanup still depends on follow-on platform migration work.\n\n## Follow-ups\n\nA broader frontend build-tool migration is still needed to retire the remaining CRA/react-scripts transitive audit debt. Two existing frontend workflow/package test suites also need targeted follow-up: `src/daily-control-loop.test.tsx` and `src/end-to-end-trust-loop.test.tsx`.\n\n## Files Created/Modified\n\n- `job-tracker-ui/package.json` — Upgraded the direct axios dependency to remove the critical frontend audit finding.\n- `job-tracker-ui/package-lock.json` — Refreshed and normalized the frontend lockfile so local install, container `npm ci`, and Docker image builds agree on the dependency graph.\n", + "full_uat_md": "# S01: Frontend dependency and build modernization baseline — UAT\n\n**Milestone:** M011\n**Written:** 2026-04-10T16:47:38.388Z\n\n# UAT\n\n## Scenario: frontend dependency hardening baseline\n1. Run `cd job-tracker-ui && npm audit --audit-level=moderate --json`.\n2. Confirm the report no longer contains a critical direct finding for `axios`.\n3. Run `cd job-tracker-ui && npm run build` and confirm the production build completes.\n4. Run `docker run --rm -v /home/pi/development/JobTracker/job-tracker-ui:/app -w /app node:20-alpine sh -lc 'npm ci --foreground-scripts=false'` and confirm the container toolchain accepts the lockfile.\n5. Run `cd /home/pi/development/JobTracker && docker compose build frontend` and confirm the frontend image builds successfully.\n\n## Expected result\n- The critical direct dependency issue is gone.\n- The frontend builds successfully both locally and in Docker.\n- Remaining audit debt is limited to transitive CRA/react-scripts tooling issues, not an unresolved direct critical dependency.\n", + "goal": "Reduce immediate frontend dependency and build-chain risk, retire the critical direct vulnerability, and establish a verified path for frontend build modernization without breaking current behavior.", + "success_criteria": "- The direct critical frontend dependency finding is removed.\n- The frontend build path is verified on the remediated dependency set.\n- The project has a concrete, tested build-tool direction: either a stabilized CRA baseline with risk retired or an implemented migration foundation with parity evidence.\n- The resulting frontend baseline is good enough to support S02 auth changes without compounding build uncertainty.", + "proof_level": "Code + dependency audit + build/test evidence", + "integration_closure": "The frontend still builds and runs against the existing API contract after dependency remediation, and the chosen build direction is documented by working code and verification evidence rather than notes alone.", + "observability_impact": "Adds dependency-audit evidence and build/test verification outputs so later slices can rely on a known frontend baseline.", + "sequence": 1, + "replan_triggered_at": null + }, + { + "milestone_id": "M011", + "id": "S02", + "title": "S02", + "status": "complete", + "risk": "high", + "depends": [], + "demo": "Users authenticate without browser-stored bearer tokens, protected routes still work, and admin/Gmail-sensitive paths remain accessible under the new session model.", + "created_at": "2026-04-10T16:33:49.541Z", + "completed_at": "2026-04-10T19:58:17.355Z", + "full_summary_md": "---\nid: S02\nparent: M011\nmilestone: M011\nprovides:\n - A safer session baseline for S03 degraded-state UX work.\n - A cookie/CSRF contract that downstream admin and Gmail flows can build on without reintroducing browser token storage.\nrequires:\n - slice: S01\n provides: stabilized frontend baseline for safe auth-layer changes.\naffects:\n - S03\nkey_files:\n - JobTrackerApi/Controllers/AuthController.cs\n - JobTrackerApi/Program.cs\n - JobTrackerApi/Services/AuthSessionOptions.cs\n - JobTrackerApi/Controllers/UsersController.cs\n - JobTrackerApi/appsettings.Development.json\n - job-tracker-ui/src/auth.ts\n - job-tracker-ui/src/api.ts\n - job-tracker-ui/src/App.tsx\n - job-tracker-ui/src/pages/LoginPage.tsx\n - job-tracker-ui/src/pages/ProfilePage.tsx\n - job-tracker-ui/src/components/GoogleAuthCard.tsx\n - job-tracker-ui/src/components/AuthStatusCard.tsx\n - job-tracker-ui/src/components/UserManagementCard.tsx\n - job-tracker-ui/src/themePrefs.ts\n - job-tracker-ui/src/login-page.test.tsx\n - JobTrackerApi.Tests/AuthAndSystemControllerTests.cs\nkey_decisions:\n - Adopt HttpOnly cookie-backed local app sessions with a separate CSRF cookie/header contract.\n - Treat `/auth/me` plus `auth-changed` as the frontend session truth source instead of localStorage/sessionStorage JWT reads.\n - Apply IP-partitioned rate limiting to login and auth-triggered email/reset paths.\npatterns_established:\n - Use server-issued cookies plus `/auth/me` for session truth instead of browser-stored access tokens.\n - Keep lightweight user-scoped client preferences separate from auth transport state.\n - Treat auth-sensitive email/reset paths as abuse-controlled surfaces, not ordinary API calls.\nobservability_surfaces:\n - Explicit CSRF cookie/header contract via `/api/auth/csrf`.\n - Cleaner frontend unauthorized/session-expired handling through `/auth/me` resolution and 401 cleanup.\n - Rate-limit rejection surface for login and auth-email endpoints.\ndrill_down_paths:\n - .gsd/milestones/M011/slices/S02/tasks/T01-SUMMARY.md\n - .gsd/milestones/M011/slices/S02/tasks/T02-SUMMARY.md\n - .gsd/milestones/M011/slices/S02/tasks/T03-SUMMARY.md\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-04-10T19:58:17.358Z\nblocker_discovered: false\n---\n\n# S02: Authentication and session hardening\n\n**Moved local auth to cookie-backed sessions, added CSRF and rate limiting, and verified protected-route behavior under the new model.**\n\n## What Happened\n\nThis slice replaced the app’s primary browser-stored bearer-token model with a cookie-backed local session contract and then hardened the sensitive auth edges around that change. T01 mapped every token assumption across frontend and API and established the target session model. T02 implemented the transport: the API now writes the local app JWT into secure cookies, exposes CSRF/logout endpoints, reads the local session from cookies, and the frontend now uses credentialed requests, `/auth/me`-based route resolution, client-side auth metadata only, and updated login/profile/admin/Google auth surfaces that no longer depend on localStorage/sessionStorage bearer tokens. T03 added abuse controls with IP-partitioned rate limiting for login and auth-email paths, updated tests to the new contract, and verified the core unauthenticated/protected-route behavior against a live frontend/API pair. During runtime verification I hit a misleading SQLite startup failure first; the root cause was launching the API outside the Development environment, which pointed it at an empty database. Restarting with `ASPNETCORE_ENVIRONMENT=Development` restored the expected local behavior, and I also added `http://localhost:3001` to development CORS to support live cookie-based verification from the local CRA server.\n\n## Verification\n\nVerified with focused API auth tests (`dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter FullyQualifiedName~Auth`), focused frontend auth tests (`cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/login-page.test.tsx src/profile-page.test.tsx`), frontend production build (`cd job-tracker-ui && npm run build`), direct HTTP checks for `GET /api/auth/csrf` and `GET /api/auth/me`, and a browser pass confirming unauthenticated `/jobs` redirects to `/login` without failed requests in the observed flow.\n\n## Requirements Advanced\n\nNone.\n\n## Requirements Validated\n\nNone.\n\n## New Requirements Surfaced\n\nNone.\n\n## Requirements Invalidated or Re-scoped\n\nNone.\n\n## Deviations\n\nBrowser-backed verification covered protected-route redirect and unauthenticated session behavior, but not a successful live login/logout round-trip because the existing local development database does not accept the placeholder admin password from `JobTrackerApi/appsettings.Development.json`.\n\n## Known Limitations\n\nA successful live browser login/logout pass was not completed in this environment because the seeded local admin account does not accept the placeholder development password. Google/Gmail-linked auth flows were preserved in code but not fully exercised end-to-end in the browser during this slice.\n\n## Follow-ups\n\nS03 should harden degraded-state UX on top of this new session model, especially explicit API-down vs empty-state handling and any remaining Google/Gmail compatibility checks that need a trusted local authenticated fixture.\n\n## Files Created/Modified\n\n- `JobTrackerApi/Controllers/AuthController.cs` — Issued cookie-backed auth sessions, CSRF cookies, logout endpoint, and updated auth contracts.\n- `JobTrackerApi/Program.cs` — Read local JWTs from cookies, enforced CSRF on mutating session requests, and added auth rate limiting.\n- `JobTrackerApi/Services/AuthSessionOptions.cs` — Centralized auth cookie/header names and cookie option helpers.\n- `job-tracker-ui/src/auth.ts` — Switched frontend request/auth helpers to credentialed requests and client metadata only.\n- `job-tracker-ui/src/api.ts` — Configured axios for cookie-backed sessions, CSRF headers, and 401 cleanup.\n- `job-tracker-ui/src/App.tsx` — Moved route protection to resolved `/auth/me` session state.\n- `job-tracker-ui/src/pages/LoginPage.tsx` — Removed token-storage assumptions from login, profile, status, Google auth, admin user management, and theme scoping.\n", + "full_uat_md": "# S02: Authentication and session hardening — UAT\n\n**Milestone:** M011\n**Written:** 2026-04-10T19:58:17.359Z\n\n# UAT — S02 Authentication and session hardening\n\n## What was exercised\n\n1. Start the API with `ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5202 dotnet run --project JobTrackerApi/JobTrackerApi.csproj`.\n2. Start the frontend with `cd job-tracker-ui && PORT=3001 BROWSER=none npm start`.\n3. Navigate to `http://localhost:3001/jobs` without an authenticated session.\n4. Confirm the app redirects to `/login` and shows the sign-in UI instead of rendering a protected route.\n5. Confirm the observed browser pass does not show failed requests for the redirect flow.\n6. Request `GET http://localhost:5202/api/auth/csrf` and confirm the response returns `204 No Content` with an `XSRF-TOKEN` cookie.\n7. Request `GET http://localhost:5202/api/auth/me` without a session and confirm it returns `401 Unauthorized`.\n\n## Result\n\n- Protected-route gating worked: unauthenticated `/jobs` redirected to `/login`.\n- The login screen rendered cleanly under the new session model.\n- CSRF bootstrap and unauthorized session responses matched the expected contract.\n- A successful live login/logout browser pass was not completed because the existing local development database does not accept the placeholder seeded admin password from config.\n\n", + "goal": "Replace the current browser-stored bearer token model with a safer primary session design, harden auth-sensitive endpoints against abuse, and preserve the app’s protected flows under the new transport.", + "success_criteria": "- Primary local auth no longer relies on localStorage/sessionStorage bearer tokens.\n- Login/logout/profile/admin flows are verified under the new session model.\n- Auth-sensitive endpoints have meaningful throttling / abuse controls.\n- Frontend unauthorized handling is explicit and coherent under the new session transport.", + "proof_level": "Code + integration + auth-flow verification", + "integration_closure": "Frontend and API must agree on how sessions are established, persisted, refreshed/expired, and invalidated. Protected routes, admin pages, and Gmail-sensitive paths must continue to function cleanly under the new auth model.", + "observability_impact": "Adds clearer session/auth diagnostics, explicit unauthorized-state behavior, and abuse-control signals for login and reset flows.", + "sequence": 2, + "replan_triggered_at": null + }, + { + "milestone_id": "M011", + "id": "S03", + "title": "S03", + "status": "complete", + "risk": "medium", + "depends": [], + "demo": "When the API is unavailable, the UI says so clearly instead of looking empty; normal data views use a centralized query/retry model and remain responsive.", + "created_at": "2026-04-10T16:33:49.541Z", + "completed_at": "2026-04-10T22:20:05.937Z", + "full_summary_md": "---\nid: S03\nparent: M011\nmilestone: M011\nprovides:\n - A resilient client layer that downstream AI and admin slices can rely on without reintroducing outage-as-empty UX.\n - Clearer failure semantics for S06 AI/Ollama capability work when backend-dependent views degrade.\nrequires:\n - slice: S02\n provides: cookie-backed auth/session transport and explicit unauthorized behavior.\naffects:\n - S04\n - S06\nkey_files:\n - job-tracker-ui/src/hooks/useViewResource.ts\n - job-tracker-ui/src/components/ViewStateNotice.tsx\n - job-tracker-ui/src/hooks/useCompanies.ts\n - job-tracker-ui/src/components/JobTable.tsx\n - job-tracker-ui/src/components/DashboardView.tsx\n - job-tracker-ui/src/components/CompaniesTable.tsx\n - job-tracker-ui/src/components/RemindersView.tsx\n - job-tracker-ui/src/components/KanbanBoard.tsx\n - job-tracker-ui/src/pages/ProfilePage.tsx\n - job-tracker-ui/src/daily-control-loop.test.tsx\nkey_decisions:\n - Use a lightweight shared async-view-state pattern instead of introducing a new global query framework during M011.\n - Make outage-state clarity a product requirement for the top-level views first: jobs, dashboard, reminders, companies, kanban, and profile.\npatterns_established:\n - Use `useViewResource` plus `ViewStateNotice` for top-level frontend data views that need distinct loading/empty/error/retry states.\n - Do not collapse transport failures into empty arrays/nulls on user-visible index pages.\n - Record environment-limited verification separately from product-behavior proof so slices stay honest.\nobservability_surfaces:\n - Explicit unavailable/error notices for jobs, dashboard, reminders, companies, kanban, and profile.\n - Shared retry surface via `ViewStateNotice` on core data views.\n - Clearer distinction between unauthorized/auth-required and general API-unavailable states on the client.\ndrill_down_paths:\n - .gsd/milestones/M011/slices/S03/tasks/T01-SUMMARY.md\n - .gsd/milestones/M011/slices/S03/tasks/T02-SUMMARY.md\n - .gsd/milestones/M011/slices/S03/tasks/T03-SUMMARY.md\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-04-10T22:20:05.940Z\nblocker_discovered: false\n---\n\n# S03: Resilience UX and client data layer\n\n**Made core frontend views show explicit unavailable states instead of masking API failures as empty data.**\n\n## What Happened\n\nThis slice retired the user-facing outage-masking problem that showed up in the earlier browser audit. T01 mapped the failure pattern: several core views were swallowing request failures into empty arrays or nulls and then rendering normal empty states. T02 introduced a shared `useViewResource` hook plus a reusable `ViewStateNotice` component, then applied that pattern to the high-traffic surfaces that matter most for top-level product trust: the jobs list, dashboard, reminders view, companies view, kanban board, shared company hook, and the profile page’s top-level load. Those views now distinguish unavailable/error states from genuine empty data and offer retry affordances where appropriate. T03 verified the new behavior: the focused frontend regression set passed, the frontend build passed, and a browser pass with the API intentionally unavailable showed `Unable to load jobs` instead of an empty jobs table. When the API auth surface was reachable again, the frontend recovered to a normal sign-in screen. During the recovery pass I hit an unrelated local API limitation — some job-data requests still fail in this checkout because the local process logs SQLite schema errors — so I recorded that as environment evidence for S04 rather than broadening the slice.\n\n## Verification\n\nVerified with focused frontend tests (`cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/login-page.test.tsx src/profile-page.test.tsx`), frontend production build (`cd job-tracker-ui && npm run build`), browser outage verification on `http://localhost:3001/jobs`, and browser recovery verification on `http://localhost:3001/login` once the API auth surface was reachable again.\n\n## Requirements Advanced\n\nNone.\n\n## Requirements Validated\n\nNone.\n\n## New Requirements Surfaced\n\nNone.\n\n## Requirements Invalidated or Re-scoped\n\nNone.\n\n## Deviations\n\nThe with-API-available browser smoke used the login/auth-reachable path rather than a full jobs-data happy path because the local development API process still has an unrelated SQLite schema issue affecting some job-data queries in this checkout.\n\n## Known Limitations\n\nDeeper detail/workspace fetches still use local fallback-on-error patterns in some components, especially `JobDetailsDialog.tsx` and `QuickCommandDialog.tsx`. The local API process in this checkout also still logs SQLite schema errors (`RuleSettings` missing) for some job-data paths, which limited the recovery browser smoke to auth-reachable surfaces instead of a full jobs-data happy path.\n\n## Follow-ups\n\nS04 should address the underlying API startup/data-layer fragility, including the local SQLite/schema inconsistencies that limited the with-API-available browser smoke during this slice. A later frontend slice can extend the shared view-state pattern into deeper detail/workspace surfaces such as `JobDetailsDialog.tsx` and `QuickCommandDialog.tsx`.\n\n## Files Created/Modified\n\n- `job-tracker-ui/src/hooks/useViewResource.ts` — Added the shared async view-state hook for loading/error/retry handling.\n- `job-tracker-ui/src/components/ViewStateNotice.tsx` — Added the shared unavailable/error surface used by core views.\n- `job-tracker-ui/src/hooks/useCompanies.ts` — Exposed error/reload state from the shared companies hook instead of silent empty fallback.\n- `job-tracker-ui/src/components/JobTable.tsx` — Stopped the jobs list from presenting API failures as ordinary empty results.\n- `job-tracker-ui/src/components/DashboardView.tsx` — Moved dashboard summary/trend loading to shared resource state and explicit unavailable notices.\n- `job-tracker-ui/src/components/CompaniesTable.tsx` — Added explicit unavailable state to the companies view.\n- `job-tracker-ui/src/components/RemindersView.tsx` — Added explicit unavailable state to the reminders view.\n- `job-tracker-ui/src/components/KanbanBoard.tsx` — Added explicit unavailable state to the kanban board.\n- `job-tracker-ui/src/pages/ProfilePage.tsx` — Added a top-level profile load failure surface instead of silent blank state.\n- `job-tracker-ui/src/daily-control-loop.test.tsx` — Updated workflow regression assertions to match the current stable package-work surface.\n", + "full_uat_md": "# S03: Resilience UX and client data layer — UAT\n\n**Milestone:** M011\n**Written:** 2026-04-10T22:20:05.941Z\n\n# UAT — S03 Resilience UX and client data layer\n\n## What was exercised\n\n1. Start the frontend only with `cd job-tracker-ui && PORT=3001 BROWSER=none npm start`.\n2. Leave the API unavailable.\n3. Navigate to `http://localhost:3001/jobs`.\n4. Confirm the jobs page shows an explicit unavailable state (`Unable to load jobs`) instead of an empty jobs table or `No jobs found.` copy.\n5. Bring the API back up to an auth-reachable state.\n6. Navigate to `http://localhost:3001/login`.\n7. Confirm the normal sign-in UI renders again once the API is reachable.\n\n## Result\n\n- The outage path is now explicit: the jobs page reports that it cannot reach the API instead of pretending there is no data.\n- The frontend returns to a normal reachable auth surface when the API is back.\n- A full jobs-data happy-path browser smoke was still limited by an unrelated local SQLite schema issue in this checkout, which should be addressed in S04.\n\n", + "goal": "Make API outages and request failures visible to users instead of looking like ordinary empty data, using a shared resilient data-loading model across the core frontend views.", + "success_criteria": "- Core data views no longer present API/network failures as ordinary empty states.\n- The frontend uses a shared query/error model for the highest-traffic data surfaces instead of ad hoc `catch(() => [])` fallbacks.\n- Unauthorized and unavailable states are visually distinct on the client.\n- Browser verification proves the app shows an explicit unavailable/error state when the API is down.", + "proof_level": "Code + browser outage verification + focused frontend tests", + "integration_closure": "Top-level data views must stop collapsing transport failures into empty-data presentations. The frontend should distinguish loading, empty, unauthorized, and unavailable states consistently while still working against the cookie-backed auth session introduced in S02.", + "observability_impact": "Adds explicit client-visible unavailable/error states and shared retry/error surfaces so API outages and request failures are diagnosable instead of looking like normal empty datasets.", + "sequence": 3, + "replan_triggered_at": null + }, + { + "milestone_id": "M011", + "id": "S04", + "title": "S04", + "status": "complete", + "risk": "high", + "depends": [], + "demo": "The API starts cleanly with slimmer startup composition, stronger edge controls, and fewer deploy-time surprises.", + "created_at": "2026-04-10T16:33:49.541Z", + "completed_at": "2026-04-10T22:44:54.405Z", + "full_summary_md": "---\nid: S04\nparent: M011\nmilestone: M011\nprovides:\n - A cleaner API startup baseline for S05 endpoint hardening.\n - A more explicit runtime boundary for S06 AI/Ollama operational hardening.\nrequires:\n - slice: S02\n provides: Cookie-backed auth/session transport and explicit auth edge behavior.\n - slice: S03\n provides: Frontend verification evidence that exposed the startup/data-layer fragility to retire.\naffects:\n - S05\n - S06\nkey_files:\n - JobTrackerApi/Program.cs\n - JobTrackerApi/Services/StartupInitializationExtensions.cs\n - JobTrackerApi/Services/StartupReadiness.cs\n - JobTrackerApi/Services/JobEnrichmentHostedService.cs\n - JobTrackerApi/Services/DailyExportHostedService.cs\n - JobTrackerApi/Services/RulesHostedService.cs\n - JobTrackerApi/Services/FollowUpReminderHostedService.cs\nkey_decisions:\n - Extract database/bootstrap orchestration out of `Program.cs` first instead of mixing it further with middleware/auth wiring.\n - Use an explicit startup-readiness gate so background workers do not infer schema safety from fixed delays alone.\npatterns_established:\n - Keep startup/bootstrap orchestration separate from middleware/controller wiring.\n - Do not let background services infer initialization readiness from fixed delays alone.\n - Treat missing core schema assumptions as a startup-readiness condition, not just a background error to log and ignore.\nobservability_surfaces:\n - Explicit startup-readiness boundary for background services.\n - Startup warning when the core schema is incomplete and background services remain paused.\n - Cleaner separation between startup/bootstrap diagnostics and post-start background worker behavior.\ndrill_down_paths:\n - .gsd/milestones/M011/slices/S04/tasks/T01-SUMMARY.md\n - .gsd/milestones/M011/slices/S04/tasks/T02-SUMMARY.md\n - .gsd/milestones/M011/slices/S04/tasks/T03-SUMMARY.md\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-04-10T22:44:54.407Z\nblocker_discovered: false\n---\n\n# S04: API startup and platform hardening\n\n**Extracted API bootstrap out of `Program.cs` and hardened background startup behavior so the app starts cleanly without the earlier missing-table failure profile.**\n\n## What Happened\n\nThis slice hardened the API startup path by separating bootstrap concerns from general host wiring and by making background-worker readiness explicit. T01 mapped the real seam: `Program.cs` was carrying service registration, auth config, provider selection, schema/bootstrap repair, migrations, seeding, and ownership claim logic all in one imperative block, while background services assumed schema readiness after fixed delays. T02 extracted that database/bootstrap block into `StartupInitializationExtensions`, registered a shared `StartupReadiness` gate, and updated the background workers most exposed to the earlier failure noise so they wait for startup readiness before beginning their own loops. A core-schema check now prevents those workers from running if startup completes without the required tables. T03 verified the new path: the API builds cleanly, the focused auth/system tests pass, and a Development startup run reaches ready without the prior `RuleSettings`/`JobApplications` missing-table error storm. The core auth surfaces remain healthy under that path (`/api/auth/config` 200, `/api/auth/me` 401 unauthenticated).\n\n## Verification\n\nVerified with `dotnet build JobTrackerApi/JobTrackerApi.csproj`, `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter AuthAndSystemControllerTests`, a Development startup run on `http://localhost:5202`, and direct HTTP checks against `/api/auth/config` and `/api/auth/me`.\n\n## Requirements Advanced\n\nNone.\n\n## Requirements Validated\n\nNone.\n\n## New Requirements Surfaced\n\nNone.\n\n## Requirements Invalidated or Re-scoped\n\nNone.\n\n## Deviations\n\nVerification focused on startup and auth surfaces rather than a broader frontend/browser path, because S04’s target risk was startup/bootstrap behavior. The bootstrap extraction was performed mechanically first and then normalized, which changed the implementation order but not the slice outcome.\n\n## Known Limitations\n\nStartup still emits EF model validation warnings related to required relationships combined with global query filters, and the summarizer probe remains part of the startup/runtime surface. Those are platform concerns to track, but the earlier missing-table startup failure class was retired in the observed pass.\n\n## Follow-ups\n\nS05 should build on this cleaner startup baseline to harden file/admin/sensitive endpoints without relying on a monolithic startup file. S06 should treat the summarizer probe and AI-service startup behavior as part of its operational boundary review.\n\n## Files Created/Modified\n\n- `JobTrackerApi/Program.cs` — Delegates startup initialization to a focused bootstrap extension and registers startup readiness.\n- `JobTrackerApi/Services/StartupInitializationExtensions.cs` — Owns database/bootstrap initialization outside `Program.cs`.\n- `JobTrackerApi/Services/StartupReadiness.cs` — Introduces an explicit startup-readiness boundary for background services.\n- `JobTrackerApi/Services/JobEnrichmentHostedService.cs` — Waits for startup readiness before running enrichment background work.\n- `JobTrackerApi/Services/DailyExportHostedService.cs` — Waits for startup readiness before running daily exports.\n- `JobTrackerApi/Services/RulesHostedService.cs` — Waits for startup readiness before applying ghosting rules in the background.\n- `JobTrackerApi/Services/FollowUpReminderHostedService.cs` — Waits for startup readiness before follow-up reminder background passes.\n", + "full_uat_md": "# S04: API startup and platform hardening — UAT\n\n**Milestone:** M011\n**Written:** 2026-04-10T22:44:54.407Z\n\n# UAT — S04 API startup and platform hardening\n\n## What was exercised\n\n1. Build the API with `dotnet build JobTrackerApi/JobTrackerApi.csproj`.\n2. Run the focused auth/system tests with `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter AuthAndSystemControllerTests`.\n3. Start the API in Development with `ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5202 dotnet run --project JobTrackerApi/JobTrackerApi.csproj`.\n4. Confirm the process reaches a ready state instead of entering the earlier missing-table error state.\n5. Request `GET http://localhost:5202/api/auth/config` and confirm it returns 200.\n6. Request `GET http://localhost:5202/api/auth/me` without a session and confirm it returns 401.\n\n## Result\n\n- The API reaches ready cleanly in the observed Development startup pass.\n- The earlier `RuleSettings` / `JobApplications` missing-table startup error storm did not recur in that pass.\n- Core auth surfaces remained healthy after the bootstrap refactor and readiness-gating changes.\n\n", + "goal": "Make API startup cleaner and more predictable by extracting startup/bootstrap responsibilities out of `Program.cs`, hardening database/bootstrap behavior, and preventing background services from tripping over partially initialized state.", + "success_criteria": "- `JobTrackerApi/Program.cs` is materially slimmer and delegates startup/bootstrap work to focused helpers or services.\n- Database/bootstrap logic no longer relies on a monolithic startup block that mixes provider detection, schema repair, seeding, and runtime wiring.\n- The API can start in the local Development environment without the observed SQLite bootstrap/schema failure path recurring for core startup assumptions.\n- Verification captures both clean startup behavior and the remaining constraints explicitly if any non-core background path is still limited.", + "proof_level": "Code + startup verification + focused tests", + "integration_closure": "The API must still start with the existing auth/session work from S02, support the resilient frontend from S03, and keep hosted/background services aligned with the post-bootstrap schema state. Startup hardening cannot break current controllers, auth wiring, or the local development DB path.", + "observability_impact": "Improves startup diagnostics and reduces false-negative runtime noise by making bootstrap phases explicit and by ensuring background services do not begin work against missing schema assumptions.", + "sequence": 4, + "replan_triggered_at": null + }, + { + "milestone_id": "M011", + "id": "S05", + "title": "S05", + "status": "complete", + "risk": "medium", + "depends": [], + "demo": "Uploads, client-error reporting, avatars, and admin/system workflows behave safely and predictably under normal and adverse input.", + "created_at": "2026-04-10T16:33:49.541Z", + "completed_at": "2026-04-10T23:00:57.771Z", + "full_summary_md": "---\nid: S05\nparent: M011\nmilestone: M011\nprovides:\n - A safer API boundary and diagnostics surface for the final AI/Ollama hardening slice.\nrequires:\n - slice: S02\n provides: Cookie-backed local session model and auth boundary conventions.\n - slice: S04\n provides: Cleaner startup/runtime baseline for endpoint hardening verification.\naffects:\n - S06\nkey_files:\n - JobTrackerApi/Controllers/AttachmentsController.cs\n - JobTrackerApi/Controllers/AuthController.cs\n - JobTrackerApi/Controllers/BackupController.cs\n - JobTrackerApi/Controllers/ExportController.cs\n - JobTrackerApi/Controllers/ClientErrorsController.cs\n - JobTrackerApi.Tests/AttachmentsControllerTests.cs\n - JobTrackerApi.Tests/BackupControllerTests.cs\n - JobTrackerApi.Tests/ClientErrorsControllerTests.cs\nkey_decisions:\n - Require explicit local auth on backup, export, and attachment routes instead of relying on implicit route exposure plus ownership filters.\n - Sanitize client-error reports by logging hashes and short previews instead of raw browser stack payloads.\n - Validate avatar uploads from detected bytes, not just client-provided MIME labels.\npatterns_established:\n - Sensitive file/export routes should declare their auth boundary explicitly instead of depending on implicit ownership filters alone.\n - Diagnostics from untrusted clients should be normalized, bounded, and hashed rather than logged raw.\n - File/avatar validation should confirm server-observed content signatures for supported formats instead of trusting the browser’s content type.\nobservability_surfaces:\n - Client-error reports now emit bounded normalized fields plus stack/component hashes and short previews instead of raw payload dumps.\n - Sensitive route denial behavior is now explicit and test-covered for attachments, backup, and export endpoints.\ndrill_down_paths:\n - .gsd/milestones/M011/slices/S05/tasks/T01-SUMMARY.md\n - .gsd/milestones/M011/slices/S05/tasks/T02-SUMMARY.md\n - .gsd/milestones/M011/slices/S05/tasks/T03-SUMMARY.md\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-04-10T23:00:57.772Z\nblocker_discovered: false\n---\n\n# S05: File, admin, and sensitive endpoint hardening\n\n**Hardened sensitive file/admin-adjacent endpoints by enforcing auth boundaries, sanitizing client-error logging, and tightening avatar validation.**\n\n## What Happened\n\nThis slice tightened the API’s sensitive endpoint boundaries and payload handling. T01 mapped the real risk seam: file routes and export/backup routes were publicly routable, avatar uploads trusted browser-provided MIME labels, and `ClientErrorsController` logged raw browser payloads verbatim. T02 implemented the hardening: attachments, backup, and export routes now require explicit local auth; avatar uploads now use a tighter size limit and server-side PNG/JPEG/WebP signature detection before persistence; and client-error logging now keeps bounded normalized fields plus hashed/summarized stack signals instead of raw submitted stacks. T03 verified both code and runtime behavior. Focused controller tests passed, the API still builds, and a live Development pass confirmed the hardened routes now reject anonymous access with 401 responses. The remaining noteworthy constraint is that anonymous `client-errors` submissions also return 401 under the current auth-required environment.\n\n## Verification\n\nVerified with `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AttachmentsControllerTests|FullyQualifiedName~BackupControllerTests|FullyQualifiedName~ClientErrorsControllerTests|FullyQualifiedName~AuthAndSystemControllerTests\"`, `dotnet build JobTrackerApi/JobTrackerApi.csproj`, a Development startup run on `http://localhost:5202`, and anonymous HTTP checks against export, backup, attachments, and client-error routes.\n\n## Requirements Advanced\n\nNone.\n\n## Requirements Validated\n\nNone.\n\n## New Requirements Surfaced\n\nNone.\n\n## Requirements Invalidated or Re-scoped\n\nNone.\n\n## Deviations\n\nThe runtime pass revealed that anonymous `POST /api/client-errors` returns 401 under the current auth-required environment. I recorded that as an observed constraint instead of broadening S05 into a pre-auth diagnostics policy change.\n\n## Known Limitations\n\nAvatar images still use the existing inline `AvatarImageDataUrl` persistence model; S05 hardens that path without redesigning storage. Anonymous `client-errors` submissions currently return 401 under the auth-required environment, so pre-auth browser failures are not captured unless that policy is revisited intentionally.\n\n## Follow-ups\n\nS06 should keep the same discipline around bounded diagnostics and explicit capability/auth surfaces when hardening the AI service and Ollama integration. If product requirements later need anonymous pre-auth browser error capture, that should be an explicit follow-up rather than an accidental open endpoint.\n\n## Files Created/Modified\n\n- `JobTrackerApi/Controllers/AttachmentsController.cs` — Added explicit local auth boundary to attachment routes.\n- `JobTrackerApi/Controllers/AuthController.cs` — Tightened avatar upload size and content validation using extension checks and server-side image signature detection.\n- `JobTrackerApi/Controllers/BackupController.cs` — Added explicit local auth boundary to encrypted backup export.\n- `JobTrackerApi/Controllers/ExportController.cs` — Added explicit local auth boundary to job export endpoints.\n- `JobTrackerApi/Controllers/ClientErrorsController.cs` — Replaced raw browser-stack logging with bounded normalized fields, stack previews, and hashes.\n- `JobTrackerApi.Tests/AttachmentsControllerTests.cs` — Added auth-boundary coverage for attachment routes.\n- `JobTrackerApi.Tests/BackupControllerTests.cs` — Added auth-boundary coverage for backup and export routes.\n- `JobTrackerApi.Tests/ClientErrorsControllerTests.cs` — Added tests for client-error sanitization and avatar signature rejection.\n", + "full_uat_md": "# S05: File, admin, and sensitive endpoint hardening — UAT\n\n**Milestone:** M011\n**Written:** 2026-04-10T23:00:57.773Z\n\n# UAT — S05 File, admin, and sensitive endpoint hardening\n\n## What was exercised\n\n1. Run focused controller tests for attachments, backup/export auth boundaries, client-error sanitization, and avatar validation.\n2. Build the API with `dotnet build JobTrackerApi/JobTrackerApi.csproj`.\n3. Start the API in Development on `http://localhost:5202`.\n4. Request the hardened routes without a session:\n - `GET /api/export/jobs`\n - `POST /api/backup/encrypted`\n - `GET /api/attachments/1`\n5. Observe the current behavior of `POST /api/client-errors` without a session in the auth-required environment.\n\n## Result\n\n- Hardened sensitive file/export routes now reject anonymous access with 401 responses.\n- Client-error logging is covered by focused tests and no longer stores raw browser stack payloads in logs.\n- Avatar uploads reject unsupported byte signatures even when the browser labels them as a supported image type.\n- Anonymous `client-errors` submissions currently return 401 in this environment and are recorded as a known limitation/constraint.\n\n", + "goal": "Harden file, admin, and sensitive endpoints so uploads, backups/exports, avatar handling, and client-error reporting behave predictably under hostile or malformed input without leaking unnecessary data.", + "success_criteria": "- Sensitive export/backup/admin/file routes require the right auth boundary.\n- Avatar and attachment upload paths validate type/size/input more defensively and avoid unsafe persistence patterns.\n- Client error reporting stops logging raw browser stack payloads while still preserving useful diagnostic signals.\n- Verification covers both allowed paths and denied/malformed-input paths.", + "proof_level": "Code + focused endpoint tests + build/test verification", + "integration_closure": "S05 must preserve the cookie-backed auth/session model from S02 and the cleaner startup/runtime baseline from S04 while tightening sensitive endpoint behavior. Hardening should not break existing attachment flows, admin status pages, or export/backup workflows for authorized users.", + "observability_impact": "Sensitive endpoint failures should become explicit and bounded: rejected uploads and client-error reports should surface clear validation outcomes, and admin/file routes should no longer rely on open routing or raw payload logging for diagnosis.", + "sequence": 5, + "replan_triggered_at": null + }, + { + "milestone_id": "M011", + "id": "S06", + "title": "S06", + "status": "complete", + "risk": "high", + "depends": [], + "demo": "AI-powered features expose clear capability states, degrade gracefully when Ollama or OCR paths are limited, and run on a cleaner service contract.", + "created_at": "2026-04-10T16:33:49.541Z", + "completed_at": "2026-04-10T23:24:52.442Z", + "full_summary_md": "---\nid: S06\nparent: M011\nmilestone: M011\nprovides:\n - A complete hardened platform baseline across frontend, auth, runtime startup, sensitive endpoints, and AI/Ollama reliability.\nrequires:\n - slice: S04\n provides: Cleaner startup/runtime baseline for background services and health verification.\n - slice: S05\n provides: Sensitive endpoint/auth hardening patterns for bounded diagnostics and explicit capability surfaces.\naffects:\n []\nkey_files:\n - tools/summarizer/app.py\n - tools/summarizer/tests/test_app.py\n - JobTrackerApi/Services/SummarizerService.cs\n - tools/summarizer/README.md\nkey_decisions:\n - Make the Python summarizer model lazy-load by default and expose the resulting state explicitly in `/health`.\n - Interpret `summarize_available=false` as unhealthy in the .NET metrics layer so admin/runtime telemetry does not misreport a disabled summarizer as healthy.\n - Keep the existing `ISummarizerService` caller contract stable for now while improving degraded-path diagnostics and capability reporting underneath it.\npatterns_established:\n - Heavy local AI models should load lazily by default unless warm-up is explicitly requested.\n - Health endpoints should report capability state without performing hidden heavyweight initialization.\n - API wrappers over optional AI services should distinguish ‘HTTP reachable’ from ‘functionally available’.\nobservability_surfaces:\n - Python `/health` now exposes explicit summarizer model capability state (`model_loaded`, `model_disabled`, `summarize_available`, `model_load_error`).\n - API-side AI metrics now treat summarize-unavailable health responses as unhealthy and preserve more specific failure text from AI/probe/OCR requests.\ndrill_down_paths:\n - .gsd/milestones/M011/slices/S06/tasks/T01-SUMMARY.md\n - .gsd/milestones/M011/slices/S06/tasks/T02-SUMMARY.md\n - .gsd/milestones/M011/slices/S06/tasks/T03-SUMMARY.md\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-04-10T23:24:52.444Z\nblocker_discovered: false\n---\n\n# S06: AI service and Ollama reliability hardening\n\n**Hardened the AI/Ollama contract so model-disabled, model-load, and unreachable-Ollama states are explicit and degrade predictably.**\n\n## What Happened\n\nThis slice hardened the AI service and Ollama integration around explicit capability state and predictable degraded behavior. T01 mapped the real seam: the Python AI service loaded the summarization model at import time unless a skip flag was set, Ollama behavior was optional but coarse, and the .NET summarizer wrapper depended heavily on post-failure metrics rather than a clearer health interpretation. T02 changed that contract. The Python service now lazy-loads the summarization model by default, tracks disabled/load-failure state explicitly, and reports `model_loaded`, `model_disabled`, `summarize_available`, and `model_load_error` through `/health` without triggering a hidden warm-up. Focused Python tests now cover disabled-model health, explicit summarize 503 behavior, and configured-but-unreachable Ollama health. On the .NET side, `SummarizerService` now records clearer error detail from failed summarize/OCR/probe responses and no longer treats `summarize_available=false` as healthy. T03 verified the new contract through the project’s Python test harness, focused .NET tests, and a clean API build. The remaining uncertainty is environmental model/runtime behavior, not hidden contract ambiguity.\n\n## Verification\n\nVerified with `cd tools/summarizer && ./scripts/bootstrap-and-test.sh test`, `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AuthAndSystemControllerTests|FullyQualifiedName~ProductionConfigTests|FullyQualifiedName~OwnershipGuardTests\"`, and `dotnet build JobTrackerApi/JobTrackerApi.csproj`.\n\n## Requirements Advanced\n\nNone.\n\n## Requirements Validated\n\nNone.\n\n## New Requirements Surfaced\n\nNone.\n\n## Requirements Invalidated or Re-scoped\n\nNone.\n\n## Deviations\n\nVerification focused on explicit degraded-path behavior and contract tests rather than running a full live Ollama deployment. That kept the slice aligned with its reliability goal instead of turning it into environment orchestration work.\n\n## Known Limitations\n\n`AiServiceMetrics` still does not have dedicated .NET fields for model-loaded/model-disabled state, so that detail is currently folded into the existing `Healthy`/`LastError` view rather than surfaced as separate typed properties. Real Ollama pull/load latency remains environment-dependent and was not the target of this slice.\n\n## Follow-ups\n\nIf later milestones need deeper AI operability work, the next seam would be widening `AiServiceMetrics` with first-class model-state fields or adding a dedicated warm-up endpoint. Actual Ollama pull/load latency remains an environment/runtime concern rather than a hidden contract issue now.\n\n## Files Created/Modified\n\n- `tools/summarizer/app.py` — Changed AI runtime to lazy model load by default and added explicit health/capability fields for model and Ollama state.\n- `tools/summarizer/tests/test_app.py` — Added focused tests for disabled-model health, disabled summarize behavior, and configured-but-unreachable Ollama state.\n- `JobTrackerApi/Services/SummarizerService.cs` — Improved API-side AI error detail capture and health interpretation for summarize-unavailable states.\n- `tools/summarizer/README.md` — Documented the expanded health/capability contract.\n", + "full_uat_md": "# S06: AI service and Ollama reliability hardening — UAT\n\n**Milestone:** M011\n**Written:** 2026-04-10T23:24:52.444Z\n\n# UAT — S06 AI service and Ollama reliability hardening\n\n## What was exercised\n\n1. Run the summarizer Python test suite with `cd tools/summarizer && ./scripts/bootstrap-and-test.sh test`.\n2. Verify the Python tests cover:\n - disabled-model `/health` state\n - explicit 503 summarize behavior when model loading is disabled\n - configured-but-unreachable Ollama health reporting\n3. Run focused .NET tests with `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AuthAndSystemControllerTests|FullyQualifiedName~ProductionConfigTests|FullyQualifiedName~OwnershipGuardTests\"`.\n4. Rebuild the API with `dotnet build JobTrackerApi/JobTrackerApi.csproj`.\n\n## Result\n\n- The Python AI service now reports explicit model/capability state without hidden warm-up on `/health`.\n- Disabled summarizer state returns a clear 503 reason on `/summarize`.\n- Configured-but-unreachable Ollama state is represented explicitly in health output.\n- The .NET wrapper still builds and tests cleanly while interpreting summarize-unavailable as unhealthy instead of falsely healthy.\n\n", + "goal": "Harden the AI service and Ollama integration so startup, probe, summarize, OCR, and CV-classification paths expose explicit capability state, degrade predictably when dependencies are missing, and avoid blocking the platform on heavy or unreachable AI components.", + "success_criteria": "- The AI service no longer treats heavy model load or optional Ollama features as an implicit always-ready contract.\n- API-side AI calls expose clearer bounded failure behavior and capability metrics instead of silent `null`/generic failure collapse where avoidable.\n- Optional Ollama-backed features degrade predictably when Ollama is missing, unreachable, or missing the configured model.\n- Verification covers both healthy and degraded AI/Ollama paths.", + "proof_level": "Code + focused API/Python tests + health/behavior verification", + "integration_closure": "S06 must preserve the frontend and admin capability surfaces already in use, keep the API startup baseline from S04 clean, and avoid breaking current CV/application-package features that depend on `ISummarizerService`. Reliability hardening should make failures more explicit without changing core user-facing semantics unnecessarily.", + "observability_impact": "AI/Ollama state should become explicit through metrics, health/capability reporting, and bounded failure surfaces so future agents can tell the difference between model-not-loaded, Ollama-not-configured, Ollama-unreachable, OCR-unavailable, and request-time service failures.", + "sequence": 6, + "replan_triggered_at": null } ], "tasks": [ @@ -1490,6 +2198,1080 @@ "observability_impact": "Produces durable acceptance proof and highlights remaining trust gaps for later milestones.", "full_plan_md": "", "sequence": 0 + }, + { + "milestone_id": "M006", + "slice_id": "S01", + "id": "T01", + "title": "Refactor Gmail connection foundation", + "status": "pending", + "one_liner": "", + "narrative": "", + "verification_result": "", + "duration": "", + "completed_at": null, + "blocker_discovered": false, + "deviations": "", + "known_issues": "", + "key_files": [], + "key_decisions": [], + "full_summary_md": "", + "description": "Extract the current Gmail OAuth/token/status behavior into a clearer foundation service seam without changing user-visible behavior yet. Review `GmailController`, `GmailOAuthService`, `GmailConnection`, `JobTrackerContext`, and current migrations/bootstrap logic. Introduce any missing sync-state fields needed for later manual sync/history work (for example last sync attempt, last sync success, last sync error, last sync mode/source) while preserving existing token storage and refresh behavior.", + "estimate": "1 context window", + "files": [ + "JobTrackerApi/Controllers/GmailController.cs", + "JobTrackerApi/Services/GmailOAuthService.cs", + "Models/GmailConnection.cs", + "Data/JobTrackerContext.cs", + "JobTrackerApi/Program.cs", + "JobTrackerApi.Tests/GmailControllerTests.cs" + ], + "verify": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests", + "inputs": [ + "Existing Gmail OAuth service", + "Existing Gmail controller endpoints", + "Current Gmail connection model" + ], + "expected_output": [ + "Updated Gmail connection model/schema/bootstrap logic", + "Service/controller seam ready for later sync work", + "Focused backend regression coverage for status/connect/disconnect behavior" + ], + "observability_impact": "", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M006", + "slice_id": "S01", + "id": "T02", + "title": "Expose Gmail sync state in UI", + "status": "pending", + "one_liner": "", + "narrative": "", + "verification_result": "", + "duration": "", + "completed_at": null, + "blocker_discovered": false, + "deviations": "", + "known_issues": "", + "key_files": [], + "key_decisions": [], + "full_summary_md": "", + "description": "Surface the refined Gmail connection/sync state in the frontend where users already manage correspondence. Keep the current per-job UX working, but make connection state, last sync state, and actionable error messages explicit and durable so later global inbox/sync flows can reuse the same language and components.", + "estimate": "1 context window", + "files": [ + "job-tracker-ui/src/components/Correspondence.tsx", + "job-tracker-ui/src/types.ts", + "job-tracker-ui/src/correspondence-gmail-import.test.tsx", + "job-tracker-ui/src/i18n/translations.ts" + ], + "verify": "cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/correspondence-gmail-import.test.tsx", + "inputs": [ + "Current Correspondence component", + "Current Gmail status API response" + ], + "expected_output": [ + "Updated correspondence/settings UI state surfaces", + "Focused frontend regression coverage for Gmail connection state", + "Reusable copy/components for later global Gmail flows" + ], + "observability_impact": "", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M006", + "slice_id": "S01", + "id": "T03", + "title": "Prepare extension seam and docs", + "status": "pending", + "one_liner": "", + "narrative": "", + "verification_result": "", + "duration": "", + "completed_at": null, + "blocker_discovered": false, + "deviations": "", + "known_issues": "", + "key_files": [], + "key_decisions": [], + "full_summary_md": "", + "description": "Prepare low-risk Phase 2 extension seams inside the Gmail foundation without turning on AI-dependent behavior. Define interfaces/data slots for future semantic disambiguation and enrichment reasons/confidence so later milestones can attach them to sync/matching outcomes. Document the foundation decisions and the planned M007-M010 sequence so later slices do not rediscover the same boundaries.", + "estimate": "0.5 context window", + "files": [ + "JobTrackerApi/Services", + "docs", + "tools/summarizer/README.md", + ".gsd/milestones/M006/M006-CONTEXT.md" + ], + "verify": "rg -n \"Phase 2|semantic|enrichment|deterministic\" docs JobTrackerApi | head -50", + "inputs": [ + "M006 context artifact", + "Current AI/Ollama patterns" + ], + "expected_output": [ + "Interface/types for future enrichment seam", + "Foundation documentation updated", + "Tests/docs clarify deterministic-first behavior" + ], + "observability_impact": "", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S01", + "id": "T01", + "title": "Audited the frontend baseline, upgraded axios to remove the critical direct vulnerability, and restored reproducible local and Docker frontend builds.", + "status": "complete", + "one_liner": "Audited the frontend baseline, upgraded axios to remove the critical direct vulnerability, and restored reproducible local and Docker frontend builds.", + "narrative": "I audited the current frontend dependency and build baseline in `job-tracker-ui`, confirmed that the only direct critical finding was `axios`, and verified that the rest of the remaining audit findings were transitive through `react-scripts` and its older build chain. I then upgraded `axios` from `^1.13.6` to `^1.15.0`, refreshed the lockfile, and fixed a contaminated build workspace where root-owned files under `job-tracker-ui/build/static` were blocking local builds. After that, I rebuilt the frontend locally and inside Docker. The Docker build still failed once because the lockfile generated by the local Node 25/npm 11 toolchain was not accepted by the Node 20/npm 10 image used in the frontend Dockerfile. I regenerated the lockfile using the same Node 20/npm 10 container toolchain, re-ran `npm ci` under that environment, and verified that the frontend image now builds successfully. The result is a stable CRA baseline with the critical direct vulnerability removed and a clear follow-on decision: do not couple the upcoming auth/session hardening slice to a framework migration, but keep the broader migration on the roadmap because CRA remains the source of most remaining frontend audit debt.", + "verification_result": "Baseline and after-state checks were run on the frontend dependency graph, local build path, npm-version compatibility, and Docker image build. The critical direct audit finding was removed, the frontend now builds locally again, and the Docker frontend image build passes after regenerating the lockfile with the container toolchain. The only failing verification left in the frontend suite was a pair of pre-existing UI tests unrelated to the axios/package-lock remediation.", + "duration": "", + "completed_at": "2026-04-10T16:45:12.983Z", + "blocker_discovered": false, + "deviations": "`npm run build` initially failed because `job-tracker-ui/build/static` contained root-owned artifacts from an earlier containerized build. I repaired the workspace by cleaning/regenerating the directory through Docker before continuing. Docker `npm ci` then exposed an npm-version-specific lockfile mismatch (`npm 11` locally vs `npm 10` in the Node 20 image), so I regenerated the lockfile with the container toolchain to restore reproducible builds.", + "known_issues": "Frontend audit debt remains concentrated behind `react-scripts` and its transitive toolchain: 27 vulnerabilities remain after the direct axios remediation (9 low, 3 moderate, 15 high). Full retirement of that debt still requires a broader build-tool migration or equivalent ecosystem move.", + "key_files": [ + "job-tracker-ui/package.json", + "job-tracker-ui/package-lock.json" + ], + "key_decisions": [ + "D019 — remediate the direct critical dependency now, keep the CRA baseline stable for the next slice, and defer broader build-tool migration to dedicated follow-on work." + ], + "full_summary_md": "---\nid: T01\nparent: S01\nmilestone: M011\nkey_files:\n - job-tracker-ui/package.json\n - job-tracker-ui/package-lock.json\nkey_decisions:\n - D019 — remediate the direct critical dependency now, keep the CRA baseline stable for the next slice, and defer broader build-tool migration to dedicated follow-on work.\nduration: \nverification_result: mixed\ncompleted_at: 2026-04-10T16:45:12.981Z\nblocker_discovered: false\n---\n\n# T01: Audited the frontend baseline, upgraded axios to remove the critical direct vulnerability, and restored reproducible local and Docker frontend builds.\n\n**Audited the frontend baseline, upgraded axios to remove the critical direct vulnerability, and restored reproducible local and Docker frontend builds.**\n\n## What Happened\n\nI audited the current frontend dependency and build baseline in `job-tracker-ui`, confirmed that the only direct critical finding was `axios`, and verified that the rest of the remaining audit findings were transitive through `react-scripts` and its older build chain. I then upgraded `axios` from `^1.13.6` to `^1.15.0`, refreshed the lockfile, and fixed a contaminated build workspace where root-owned files under `job-tracker-ui/build/static` were blocking local builds. After that, I rebuilt the frontend locally and inside Docker. The Docker build still failed once because the lockfile generated by the local Node 25/npm 11 toolchain was not accepted by the Node 20/npm 10 image used in the frontend Dockerfile. I regenerated the lockfile using the same Node 20/npm 10 container toolchain, re-ran `npm ci` under that environment, and verified that the frontend image now builds successfully. The result is a stable CRA baseline with the critical direct vulnerability removed and a clear follow-on decision: do not couple the upcoming auth/session hardening slice to a framework migration, but keep the broader migration on the roadmap because CRA remains the source of most remaining frontend audit debt.\n\n## Verification\n\nBaseline and after-state checks were run on the frontend dependency graph, local build path, npm-version compatibility, and Docker image build. The critical direct audit finding was removed, the frontend now builds locally again, and the Docker frontend image build passes after regenerating the lockfile with the container toolchain. The only failing verification left in the frontend suite was a pair of pre-existing UI tests unrelated to the axios/package-lock remediation.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `Baseline: `cd job-tracker-ui && npm audit --audit-level=moderate --json` showed 28 vulnerabilities including 1 critical direct finding on axios <1.15.0.` | -1 | unknown (coerced from string) | 0ms |\n| 2 | `Baseline: `cd job-tracker-ui && npm run build` initially failed with EACCES because `job-tracker-ui/build/static` contained root-owned files.` | -1 | unknown (coerced from string) | 0ms |\n| 3 | `After remediation: `cd job-tracker-ui && npm install` updated axios to 1.15.0 and removed the direct critical vulnerability.` | -1 | unknown (coerced from string) | 0ms |\n| 4 | `After remediation: `cd job-tracker-ui && npm audit --audit-level=moderate --json` showed 27 remaining vulnerabilities and 0 critical findings.` | -1 | unknown (coerced from string) | 0ms |\n| 5 | `After remediation: `cd job-tracker-ui && npm run build` completed successfully.` | -1 | unknown (coerced from string) | 0ms |\n| 6 | `Compatibility check: `docker run --rm -v ... node:20-alpine sh -lc 'npm ci'` initially failed on a lockfile mismatch (`Missing: yaml@2.8.3 from lock file`).` | -1 | unknown (coerced from string) | 0ms |\n| 7 | `Compatibility fix: `docker run --rm --user 1000:1000 -v ... node:20-alpine sh -lc 'npm install'` regenerated the lockfile using the same npm major version as the Docker image.` | -1 | unknown (coerced from string) | 0ms |\n| 8 | `After compatibility fix: `docker run --rm -v ... node:20-alpine sh -lc 'npm ci --foreground-scripts=false'` completed successfully.` | -1 | unknown (coerced from string) | 0ms |\n| 9 | `Container verification: `cd /home/pi/development/JobTracker && docker compose build frontend` completed successfully.` | -1 | unknown (coerced from string) | 0ms |\n| 10 | `Regression signal: `cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false` finished with 16 passing suites and 2 failing suites (`daily-control-loop.test.tsx`, `end-to-end-trust-loop.test.tsx`) that appear unrelated to the dependency remediation itself.` | -1 | unknown (coerced from string) | 0ms |\n\n## Deviations\n\n`npm run build` initially failed because `job-tracker-ui/build/static` contained root-owned artifacts from an earlier containerized build. I repaired the workspace by cleaning/regenerating the directory through Docker before continuing. Docker `npm ci` then exposed an npm-version-specific lockfile mismatch (`npm 11` locally vs `npm 10` in the Node 20 image), so I regenerated the lockfile with the container toolchain to restore reproducible builds.\n\n## Known Issues\n\nFrontend audit debt remains concentrated behind `react-scripts` and its transitive toolchain: 27 vulnerabilities remain after the direct axios remediation (9 low, 3 moderate, 15 high). Full retirement of that debt still requires a broader build-tool migration or equivalent ecosystem move.\n\n## Files Created/Modified\n\n- `job-tracker-ui/package.json`\n- `job-tracker-ui/package-lock.json`\n", + "description": "1. Inspect the current frontend toolchain and dependency graph in `job-tracker-ui/`.\n2. Re-run and capture the current audit/build baseline (`npm audit`, install/build/test commands) so the slice has a before/after proof point.\n3. Identify which findings are direct, which are transitive through `react-scripts`, and what minimum safe upgrades are possible without migration.\n4. Produce a short implementation decision for this slice: immediate package remediation only, or package remediation plus build-tool migration foundation if that is the smallest credible way to retire the risk.", + "estimate": "0.5-1 day", + "files": [ + "job-tracker-ui/package.json", + "job-tracker-ui/package-lock.json", + "job-tracker-ui/", + "docker-compose.yml" + ], + "verify": "cd job-tracker-ui && npm audit --audit-level=moderate || true\ncd job-tracker-ui && npm run build", + "inputs": [ + "job-tracker-ui/package.json", + "existing `npm audit` results", + "current frontend build/test scripts" + ], + "expected_output": [ + "Verified dependency/audit baseline for the current frontend", + "Concrete remediation strategy grounded in the actual package graph" + ], + "observability_impact": "Creates the slice’s baseline proof for dependency risk and build health.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S01", + "id": "T02", + "title": "Applied the frontend dependency remediation and restored a reproducible build path for both local and Docker builds.", + "status": "complete", + "one_liner": "Applied the frontend dependency remediation and restored a reproducible build path for both local and Docker builds.", + "narrative": "I implemented the smallest safe dependency remediation by upgrading the direct `axios` dependency to `^1.15.0` and refreshing the lockfile accordingly. I also normalized the lockfile against the Node 20/npm 10 toolchain used by the frontend Docker image so that `npm ci` and `docker compose build frontend` remain reproducible. I did not start a framework migration in this task because the audit evidence showed that the critical risk could be retired with a much smaller change surface, and the upcoming auth/session slice should not inherit migration churn unnecessarily.", + "verification_result": "The remediated dependency set was verified through local install/build, container `npm ci`, and a full frontend Docker image build. The direct critical axios finding is no longer present.", + "duration": "", + "completed_at": "2026-04-10T16:46:52.965Z", + "blocker_discovered": false, + "deviations": "The implementation change itself was minimal: only the direct vulnerable dependency and lockfile needed updates. The larger build-path work was environmental rather than architectural, centered on root-owned build artifacts and npm-version lockfile compatibility.", + "known_issues": "The broader `react-scripts` transitive vulnerability set is still present and remains scheduled follow-up work under the milestone’s frontend-platform hardening track.", + "key_files": [ + "job-tracker-ui/package.json", + "job-tracker-ui/package-lock.json" + ], + "key_decisions": [ + "D019 — keep the CRA baseline stable for the next slice instead of coupling auth hardening to a framework migration." + ], + "full_summary_md": "---\nid: T02\nparent: S01\nmilestone: M011\nkey_files:\n - job-tracker-ui/package.json\n - job-tracker-ui/package-lock.json\nkey_decisions:\n - D019 — keep the CRA baseline stable for the next slice instead of coupling auth hardening to a framework migration.\nduration: \nverification_result: mixed\ncompleted_at: 2026-04-10T16:46:52.964Z\nblocker_discovered: false\n---\n\n# T02: Applied the frontend dependency remediation and restored a reproducible build path for both local and Docker builds.\n\n**Applied the frontend dependency remediation and restored a reproducible build path for both local and Docker builds.**\n\n## What Happened\n\nI implemented the smallest safe dependency remediation by upgrading the direct `axios` dependency to `^1.15.0` and refreshing the lockfile accordingly. I also normalized the lockfile against the Node 20/npm 10 toolchain used by the frontend Docker image so that `npm ci` and `docker compose build frontend` remain reproducible. I did not start a framework migration in this task because the audit evidence showed that the critical risk could be retired with a much smaller change surface, and the upcoming auth/session slice should not inherit migration churn unnecessarily.\n\n## Verification\n\nThe remediated dependency set was verified through local install/build, container `npm ci`, and a full frontend Docker image build. The direct critical axios finding is no longer present.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | ``cd job-tracker-ui && npm install` updated axios to 1.15.0 and synchronized the package tree.` | -1 | unknown (coerced from string) | 0ms |\n| 2 | ``cd job-tracker-ui && npm run build` succeeded after the workspace cleanup.` | -1 | unknown (coerced from string) | 0ms |\n| 3 | ``docker run --rm --user 1000:1000 -v /home/pi/development/JobTracker/job-tracker-ui:/app -w /app node:20-alpine sh -lc 'npm install'` regenerated the lockfile for the container toolchain.` | -1 | unknown (coerced from string) | 0ms |\n| 4 | ``docker run --rm -v /home/pi/development/JobTracker/job-tracker-ui:/app -w /app node:20-alpine sh -lc 'npm ci --foreground-scripts=false'` succeeded.` | -1 | unknown (coerced from string) | 0ms |\n| 5 | ``cd /home/pi/development/JobTracker && docker compose build frontend` succeeded.` | -1 | unknown (coerced from string) | 0ms |\n\n## Deviations\n\nThe implementation change itself was minimal: only the direct vulnerable dependency and lockfile needed updates. The larger build-path work was environmental rather than architectural, centered on root-owned build artifacts and npm-version lockfile compatibility.\n\n## Known Issues\n\nThe broader `react-scripts` transitive vulnerability set is still present and remains scheduled follow-up work under the milestone’s frontend-platform hardening track.\n\n## Files Created/Modified\n\n- `job-tracker-ui/package.json`\n- `job-tracker-ui/package-lock.json`\n", + "description": "1. Update direct vulnerable dependencies first, starting with the critical direct package.\n2. Apply the smallest safe lockfile/package changes that materially reduce the current risk.\n3. If `react-scripts` remains the blocker, introduce the minimum viable migration foundation needed to move away from it safely instead of forcing partial unsafe upgrades.\n4. Keep the existing app behavior and API integration intact while changing the build baseline.", + "estimate": "1-2 days", + "files": [ + "job-tracker-ui/package.json", + "job-tracker-ui/package-lock.json", + "job-tracker-ui/Dockerfile", + "job-tracker-ui/nginx.conf", + "job-tracker-ui/public/index.html", + "job-tracker-ui/src/index.tsx" + ], + "verify": "cd job-tracker-ui && npm install\ncd job-tracker-ui && npm run build", + "inputs": [ + "T01 findings", + "current CRA/Vite-compatible frontend entrypoints and build config" + ], + "expected_output": [ + "Remediated frontend dependency set", + "Updated lockfile and build configuration matching the chosen direction" + ], + "observability_impact": "Reduces dependency risk and proves the new build path is executable in this worktree.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S01", + "id": "T03", + "title": "Verified the remediated frontend baseline, quantified the remaining CRA-driven audit debt, and recorded the stable platform direction for the next slice.", + "status": "complete", + "one_liner": "Verified the remediated frontend baseline, quantified the remaining CRA-driven audit debt, and recorded the stable platform direction for the next slice.", + "narrative": "I reran the frontend audit, local production build, container install path, and Docker image build to establish the after-state baseline for S01. The critical direct dependency issue is gone, the local and Docker build paths are both working, and the remaining audit debt is now clearly attributable to the older CRA/react-scripts toolchain rather than the direct application dependency set. I also ran the full frontend test suite to measure regression risk. Most suites passed, but two existing workflow/package tests still fail for reasons outside the dependency remediation. With that evidence in hand, the platform direction for this slice is now explicit: the frontend can safely proceed into auth hardening on a stabilized CRA baseline, but a broader build-tool migration remains necessary later to remove the remaining transitive audit debt.", + "verification_result": "After-state verification included frontend audit, local build, container `npm ci`, Docker image build, and full frontend test execution. The baseline is reproducible, the critical direct vulnerability is retired, and remaining failures are isolated as follow-up work rather than hidden.", + "duration": "", + "completed_at": "2026-04-10T16:47:07.042Z", + "blocker_discovered": false, + "deviations": "The final verification surfaced two failing frontend suites that were not introduced by the dependency change and appear to be pre-existing behavioral drift in workflow/package UI tests. I recorded them as remaining issues instead of expanding this task into unrelated UI behavior fixes.", + "known_issues": "`CI=true npm test -- --runInBand --watch=false` still reports failing suites in `src/daily-control-loop.test.tsx` and `src/end-to-end-trust-loop.test.tsx`, with assertions around expected workflow/package text and saved package values. Those failures need separate follow-up and are not explained by the axios/package-lock update alone.", + "key_files": [ + "job-tracker-ui/package.json", + "job-tracker-ui/package-lock.json" + ], + "key_decisions": [ + "D019 — stabilize the CRA baseline now and defer the larger migration decision to a later dedicated step." + ], + "full_summary_md": "---\nid: T03\nparent: S01\nmilestone: M011\nkey_files:\n - job-tracker-ui/package.json\n - job-tracker-ui/package-lock.json\nkey_decisions:\n - D019 — stabilize the CRA baseline now and defer the larger migration decision to a later dedicated step.\nduration: \nverification_result: mixed\ncompleted_at: 2026-04-10T16:47:07.041Z\nblocker_discovered: false\n---\n\n# T03: Verified the remediated frontend baseline, quantified the remaining CRA-driven audit debt, and recorded the stable platform direction for the next slice.\n\n**Verified the remediated frontend baseline, quantified the remaining CRA-driven audit debt, and recorded the stable platform direction for the next slice.**\n\n## What Happened\n\nI reran the frontend audit, local production build, container install path, and Docker image build to establish the after-state baseline for S01. The critical direct dependency issue is gone, the local and Docker build paths are both working, and the remaining audit debt is now clearly attributable to the older CRA/react-scripts toolchain rather than the direct application dependency set. I also ran the full frontend test suite to measure regression risk. Most suites passed, but two existing workflow/package tests still fail for reasons outside the dependency remediation. With that evidence in hand, the platform direction for this slice is now explicit: the frontend can safely proceed into auth hardening on a stabilized CRA baseline, but a broader build-tool migration remains necessary later to remove the remaining transitive audit debt.\n\n## Verification\n\nAfter-state verification included frontend audit, local build, container `npm ci`, Docker image build, and full frontend test execution. The baseline is reproducible, the critical direct vulnerability is retired, and remaining failures are isolated as follow-up work rather than hidden.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | ``cd job-tracker-ui && npm audit --audit-level=moderate --json` now reports 0 critical findings and 27 remaining transitive findings.` | -1 | unknown (coerced from string) | 0ms |\n| 2 | ``cd job-tracker-ui && npm run build` succeeded.` | -1 | unknown (coerced from string) | 0ms |\n| 3 | ``docker run --rm -v /home/pi/development/JobTracker/job-tracker-ui:/app -w /app node:20-alpine sh -lc 'npm ci --foreground-scripts=false'` succeeded.` | -1 | unknown (coerced from string) | 0ms |\n| 4 | ``cd /home/pi/development/JobTracker && docker compose build frontend` succeeded.` | -1 | unknown (coerced from string) | 0ms |\n| 5 | ``cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false` produced 16 passing suites and 2 failing suites, leaving a clear follow-up list instead of an unverified baseline.` | -1 | unknown (coerced from string) | 0ms |\n\n## Deviations\n\nThe final verification surfaced two failing frontend suites that were not introduced by the dependency change and appear to be pre-existing behavioral drift in workflow/package UI tests. I recorded them as remaining issues instead of expanding this task into unrelated UI behavior fixes.\n\n## Known Issues\n\n`CI=true npm test -- --runInBand --watch=false` still reports failing suites in `src/daily-control-loop.test.tsx` and `src/end-to-end-trust-loop.test.tsx`, with assertions around expected workflow/package text and saved package values. Those failures need separate follow-up and are not explained by the axios/package-lock update alone.\n\n## Files Created/Modified\n\n- `job-tracker-ui/package.json`\n- `job-tracker-ui/package-lock.json`\n", + "description": "1. Run focused frontend tests and any app-entry smoke verification needed after the dependency/build changes.\n2. Re-run `npm audit` and compare against the baseline to confirm the critical direct issue is retired and quantify remaining transitive debt.\n3. Validate the frontend container/build path still works with the chosen approach.\n4. Document the resulting baseline and any intentionally deferred migration work so later slices are not guessing.", + "estimate": "0.5-1 day", + "files": [ + "job-tracker-ui/package.json", + "job-tracker-ui/package-lock.json", + "job-tracker-ui/Dockerfile", + "job-tracker-ui/src/", + "docker-compose.yml" + ], + "verify": "cd job-tracker-ui && npm audit --audit-level=moderate || true\ncd job-tracker-ui && CI=true npm test -- --runInBand --watch=false\ncd job-tracker-ui && npm run build\ncd /home/pi/development/JobTracker && docker compose build frontend", + "inputs": [ + "Updated frontend from T02", + "existing frontend tests" + ], + "expected_output": [ + "After-state audit/build/test evidence", + "Documented frontend platform direction and deferred debt list" + ], + "observability_impact": "Produces the slice-level evidence that S01 actually retired risk instead of only moving packages around.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S02", + "id": "T01", + "title": "Mapped the existing bearer-token auth surface and defined the cookie-backed target session model for S02.", + "status": "complete", + "one_liner": "Mapped the existing bearer-token auth surface and defined the cookie-backed target session model for S02.", + "narrative": "I mapped the full current auth/session surface across the frontend and API. The frontend presently treats a JWT stored in `localStorage` or `sessionStorage` as the primary session source of truth: `auth.ts` persists it, `api.ts` attaches it as a Bearer header, `App.tsx` gates protected routes based on whether the token exists, and components like `GoogleAuthCard`, `AuthStatusCard`, `UserManagementCard`, and `themePrefs.ts` read directly from that token or its decoded payload. On the API side, local app auth is JWT Bearer-based, with the local token issued by `TokenService` and returned from `/auth/login`, `/auth/register`, and `/auth/google/exchange`; authorization then depends on the `Authorization: Bearer` header path configured in `Program.cs`. That map made the migration seam clear: if we only remove browser storage without changing the API transport and frontend route guards together, we will break protected flows. I therefore defined the target model for S02 as an HttpOnly cookie-backed app session for the primary local auth path, with the API reading the local app JWT from a cookie, the frontend moving away from browser-stored app tokens as the primary session source, and CSRF protection added for state-changing requests. Google credential exchange should remain server-side and issue the same app session transport so the app has one coherent authenticated state model.", + "verification_result": "Verified the current auth/session surface with targeted code search and file inspection across the frontend auth helpers, protected-route logic, Google sign-in flow, and API auth/token setup. The complete list of token assumptions and the replacement session seam are now explicit.", + "duration": "", + "completed_at": "2026-04-10T16:49:40.585Z", + "blocker_discovered": false, + "deviations": "None.", + "known_issues": "Google sign-in and a few frontend preference helpers currently treat a browser-stored JWT as the source of truth, so the implementation task needs to update those touchpoints together rather than piecemeal.", + "key_files": [ + "job-tracker-ui/src/auth.ts", + "job-tracker-ui/src/api.ts", + "job-tracker-ui/src/pages/LoginPage.tsx", + "job-tracker-ui/src/components/GoogleAuthCard.tsx", + "job-tracker-ui/src/components/AuthStatusCard.tsx", + "job-tracker-ui/src/themePrefs.ts", + "job-tracker-ui/src/components/UserManagementCard.tsx", + "job-tracker-ui/src/App.tsx", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Services/TokenService.cs", + "JobTrackerApi/Program.cs" + ], + "key_decisions": [ + "D020 — replace browser-stored bearer tokens with an HttpOnly cookie-backed app session and explicit CSRF handling for state-changing requests." + ], + "full_summary_md": "---\nid: T01\nparent: S02\nmilestone: M011\nkey_files:\n - job-tracker-ui/src/auth.ts\n - job-tracker-ui/src/api.ts\n - job-tracker-ui/src/pages/LoginPage.tsx\n - job-tracker-ui/src/components/GoogleAuthCard.tsx\n - job-tracker-ui/src/components/AuthStatusCard.tsx\n - job-tracker-ui/src/themePrefs.ts\n - job-tracker-ui/src/components/UserManagementCard.tsx\n - job-tracker-ui/src/App.tsx\n - JobTrackerApi/Controllers/AuthController.cs\n - JobTrackerApi/Services/TokenService.cs\n - JobTrackerApi/Program.cs\nkey_decisions:\n - D020 — replace browser-stored bearer tokens with an HttpOnly cookie-backed app session and explicit CSRF handling for state-changing requests.\nduration: \nverification_result: mixed\ncompleted_at: 2026-04-10T16:49:40.584Z\nblocker_discovered: false\n---\n\n# T01: Mapped the existing bearer-token auth surface and defined the cookie-backed target session model for S02.\n\n**Mapped the existing bearer-token auth surface and defined the cookie-backed target session model for S02.**\n\n## What Happened\n\nI mapped the full current auth/session surface across the frontend and API. The frontend presently treats a JWT stored in `localStorage` or `sessionStorage` as the primary session source of truth: `auth.ts` persists it, `api.ts` attaches it as a Bearer header, `App.tsx` gates protected routes based on whether the token exists, and components like `GoogleAuthCard`, `AuthStatusCard`, `UserManagementCard`, and `themePrefs.ts` read directly from that token or its decoded payload. On the API side, local app auth is JWT Bearer-based, with the local token issued by `TokenService` and returned from `/auth/login`, `/auth/register`, and `/auth/google/exchange`; authorization then depends on the `Authorization: Bearer` header path configured in `Program.cs`. That map made the migration seam clear: if we only remove browser storage without changing the API transport and frontend route guards together, we will break protected flows. I therefore defined the target model for S02 as an HttpOnly cookie-backed app session for the primary local auth path, with the API reading the local app JWT from a cookie, the frontend moving away from browser-stored app tokens as the primary session source, and CSRF protection added for state-changing requests. Google credential exchange should remain server-side and issue the same app session transport so the app has one coherent authenticated state model.\n\n## Verification\n\nVerified the current auth/session surface with targeted code search and file inspection across the frontend auth helpers, protected-route logic, Google sign-in flow, and API auth/token setup. The complete list of token assumptions and the replacement session seam are now explicit.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | ``rg -n \"authToken|Authorization|Bearer|clearAuthToken|setAuthToken|getAuthToken|JwtBearer|TokenService|request-password-reset|login|logout\" job-tracker-ui/src JobTrackerApi -S` enumerated the frontend and API auth touchpoints.` | -1 | unknown (coerced from string) | 0ms |\n| 2 | `Reviewed `job-tracker-ui/src/auth.ts` and confirmed that localStorage/sessionStorage currently hold the app token.` | -1 | unknown (coerced from string) | 0ms |\n| 3 | `Reviewed `job-tracker-ui/src/api.ts` and confirmed the Authorization header is attached from browser storage on each request.` | -1 | unknown (coerced from string) | 0ms |\n| 4 | `Reviewed `job-tracker-ui/src/App.tsx`, `LoginPage.tsx`, `GoogleAuthCard.tsx`, `AuthStatusCard.tsx`, `UserManagementCard.tsx`, and `themePrefs.ts` to identify direct frontend dependencies on browser-stored JWT state.` | -1 | unknown (coerced from string) | 0ms |\n| 5 | `Reviewed `JobTrackerApi/Controllers/AuthController.cs`, `JobTrackerApi/Services/TokenService.cs`, and `JobTrackerApi/Program.cs` to confirm local app auth is currently JWT Bearer-based and returned directly to the frontend.` | -1 | unknown (coerced from string) | 0ms |\n\n## Deviations\n\nNone.\n\n## Known Issues\n\nGoogle sign-in and a few frontend preference helpers currently treat a browser-stored JWT as the source of truth, so the implementation task needs to update those touchpoints together rather than piecemeal.\n\n## Files Created/Modified\n\n- `job-tracker-ui/src/auth.ts`\n- `job-tracker-ui/src/api.ts`\n- `job-tracker-ui/src/pages/LoginPage.tsx`\n- `job-tracker-ui/src/components/GoogleAuthCard.tsx`\n- `job-tracker-ui/src/components/AuthStatusCard.tsx`\n- `job-tracker-ui/src/themePrefs.ts`\n- `job-tracker-ui/src/components/UserManagementCard.tsx`\n- `job-tracker-ui/src/App.tsx`\n- `JobTrackerApi/Controllers/AuthController.cs`\n- `JobTrackerApi/Services/TokenService.cs`\n- `JobTrackerApi/Program.cs`\n", + "description": "1. Inspect the current auth/session path across frontend and API: login, token issuance, request auth, logout, protected-route checks, and unauthorized handling.\n2. Identify every place that assumes a browser-stored bearer token (`localStorage`, `sessionStorage`, Authorization headers).\n3. Design the target session model for this app: secure cookie issuance, server expectations, CSRF strategy for state-changing requests, and compatibility with admin and Gmail-related flows.\n4. Record the migration seam so implementation can proceed without partial mixed-state confusion.", + "estimate": "0.5-1 day", + "files": [ + "job-tracker-ui/src/auth.ts", + "job-tracker-ui/src/api.ts", + "job-tracker-ui/src/App.tsx", + "job-tracker-ui/src/pages/LoginPage.tsx", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Services/TokenService.cs", + "JobTrackerApi/Program.cs" + ], + "verify": "rg -n \"authToken|Authorization|Bearer|clearAuthToken|setAuthToken|JwtBearer|TokenService|request-password-reset|login\" job-tracker-ui/src JobTrackerApi -S", + "inputs": [ + "Current frontend auth helpers", + "Current API auth/token setup", + "S01 stabilized frontend baseline" + ], + "expected_output": [ + "Concrete auth/session migration design grounded in current code", + "Exact list of frontend and API touchpoints to change" + ], + "observability_impact": "Creates the baseline map for auth/session diagnostics and compatibility checks.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S02", + "id": "T02", + "title": "Replaced browser-stored bearer auth with a cookie-backed session transport across the frontend and API.", + "status": "complete", + "one_liner": "Replaced browser-stored bearer auth with a cookie-backed session transport across the frontend and API.", + "narrative": "I rewired the primary local auth path so the API now issues the app JWT into secure cookies instead of returning it for browser storage, and the frontend now operates on session state rather than persisted bearer tokens. On the frontend, `job-tracker-ui/src/auth.ts` now manages only lightweight client auth metadata and CSRF helpers; `job-tracker-ui/src/api.ts` uses `withCredentials`, axios XSRF settings, and explicit 401 cleanup; `job-tracker-ui/src/App.tsx` resolves auth through `/auth/me` plus `auth-changed` events for route gating; and `job-tracker-ui/src/pages/LoginPage.tsx`, `src/pages/ProfilePage.tsx`, `src/components/GoogleAuthCard.tsx`, `src/components/AuthStatusCard.tsx`, `src/components/UserManagementCard.tsx`, and `src/themePrefs.ts` were updated to stop depending on browser-stored JWTs. On the API side, `JobTrackerApi/Controllers/AuthController.cs` now signs users in by setting session and CSRF cookies, adds logout and CSRF endpoints, and accepts `RememberMe` on local and Google auth requests. `JobTrackerApi/Program.cs` now reads the local JWT from the auth cookie and enforces CSRF on mutating requests for authenticated sessions. `JobTrackerApi/Services/AuthSessionOptions.cs` centralizes cookie/header naming and cookie settings so the server transport stays coherent.", + "verification_result": "Verified the new transport with focused API auth tests and updated frontend auth tests. The API project builds cleanly with cookie-based JWT extraction and CSRF enforcement in place, and the frontend builds cleanly after removing token-storage assumptions from login/profile/admin/Google auth surfaces.", + "duration": "", + "completed_at": "2026-04-10T19:57:16.245Z", + "blocker_discovered": false, + "deviations": "None.", + "known_issues": "A full live browser login/logout round-trip could not be completed against the existing local database because `admin@example.com` in this checkout does not accept the placeholder development password from `appsettings.Development.json`. The transport itself is covered by focused tests; browser verification was limited to protected-route and unauthenticated session behavior.", + "key_files": [ + "job-tracker-ui/src/auth.ts", + "job-tracker-ui/src/api.ts", + "job-tracker-ui/src/App.tsx", + "job-tracker-ui/src/pages/LoginPage.tsx", + "job-tracker-ui/src/pages/ProfilePage.tsx", + "job-tracker-ui/src/components/GoogleAuthCard.tsx", + "job-tracker-ui/src/components/AuthStatusCard.tsx", + "job-tracker-ui/src/components/UserManagementCard.tsx", + "job-tracker-ui/src/themePrefs.ts", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Program.cs", + "JobTrackerApi/Services/AuthSessionOptions.cs" + ], + "key_decisions": [ + "Keep bearer token generation on the server but move browser transport to secure cookies.", + "Use `/auth/me` plus an `auth-changed` event as the frontend session truth source instead of localStorage/sessionStorage JWT reads.", + "Pair the session cookie with a separate CSRF cookie/header contract for mutating requests." + ], + "full_summary_md": "---\nid: T02\nparent: S02\nmilestone: M011\nkey_files:\n - job-tracker-ui/src/auth.ts\n - job-tracker-ui/src/api.ts\n - job-tracker-ui/src/App.tsx\n - job-tracker-ui/src/pages/LoginPage.tsx\n - job-tracker-ui/src/pages/ProfilePage.tsx\n - job-tracker-ui/src/components/GoogleAuthCard.tsx\n - job-tracker-ui/src/components/AuthStatusCard.tsx\n - job-tracker-ui/src/components/UserManagementCard.tsx\n - job-tracker-ui/src/themePrefs.ts\n - JobTrackerApi/Controllers/AuthController.cs\n - JobTrackerApi/Program.cs\n - JobTrackerApi/Services/AuthSessionOptions.cs\nkey_decisions:\n - Keep bearer token generation on the server but move browser transport to secure cookies.\n - Use `/auth/me` plus an `auth-changed` event as the frontend session truth source instead of localStorage/sessionStorage JWT reads.\n - Pair the session cookie with a separate CSRF cookie/header contract for mutating requests.\nduration: \nverification_result: passed\ncompleted_at: 2026-04-10T19:57:16.244Z\nblocker_discovered: false\n---\n\n# T02: Replaced browser-stored bearer auth with a cookie-backed session transport across the frontend and API.\n\n**Replaced browser-stored bearer auth with a cookie-backed session transport across the frontend and API.**\n\n## What Happened\n\nI rewired the primary local auth path so the API now issues the app JWT into secure cookies instead of returning it for browser storage, and the frontend now operates on session state rather than persisted bearer tokens. On the frontend, `job-tracker-ui/src/auth.ts` now manages only lightweight client auth metadata and CSRF helpers; `job-tracker-ui/src/api.ts` uses `withCredentials`, axios XSRF settings, and explicit 401 cleanup; `job-tracker-ui/src/App.tsx` resolves auth through `/auth/me` plus `auth-changed` events for route gating; and `job-tracker-ui/src/pages/LoginPage.tsx`, `src/pages/ProfilePage.tsx`, `src/components/GoogleAuthCard.tsx`, `src/components/AuthStatusCard.tsx`, `src/components/UserManagementCard.tsx`, and `src/themePrefs.ts` were updated to stop depending on browser-stored JWTs. On the API side, `JobTrackerApi/Controllers/AuthController.cs` now signs users in by setting session and CSRF cookies, adds logout and CSRF endpoints, and accepts `RememberMe` on local and Google auth requests. `JobTrackerApi/Program.cs` now reads the local JWT from the auth cookie and enforces CSRF on mutating requests for authenticated sessions. `JobTrackerApi/Services/AuthSessionOptions.cs` centralizes cookie/header naming and cookie settings so the server transport stays coherent.\n\n## Verification\n\nVerified the new transport with focused API auth tests and updated frontend auth tests. The API project builds cleanly with cookie-based JWT extraction and CSRF enforcement in place, and the frontend builds cleanly after removing token-storage assumptions from login/profile/admin/Google auth surfaces.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter FullyQualifiedName~Auth` | 0 | ✅ pass | 163ms |\n| 2 | `cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/login-page.test.tsx src/profile-page.test.tsx` | 0 | ✅ pass | 15267ms |\n| 3 | `dotnet build JobTrackerApi/JobTrackerApi.csproj` | 0 | ✅ pass | 5430ms |\n| 4 | `cd job-tracker-ui && npm run build` | 0 | ✅ pass | 0ms |\n\n## Deviations\n\nNone.\n\n## Known Issues\n\nA full live browser login/logout round-trip could not be completed against the existing local database because `admin@example.com` in this checkout does not accept the placeholder development password from `appsettings.Development.json`. The transport itself is covered by focused tests; browser verification was limited to protected-route and unauthenticated session behavior.\n\n## Files Created/Modified\n\n- `job-tracker-ui/src/auth.ts`\n- `job-tracker-ui/src/api.ts`\n- `job-tracker-ui/src/App.tsx`\n- `job-tracker-ui/src/pages/LoginPage.tsx`\n- `job-tracker-ui/src/pages/ProfilePage.tsx`\n- `job-tracker-ui/src/components/GoogleAuthCard.tsx`\n- `job-tracker-ui/src/components/AuthStatusCard.tsx`\n- `job-tracker-ui/src/components/UserManagementCard.tsx`\n- `job-tracker-ui/src/themePrefs.ts`\n- `JobTrackerApi/Controllers/AuthController.cs`\n- `JobTrackerApi/Program.cs`\n- `JobTrackerApi/Services/AuthSessionOptions.cs`\n", + "description": "1. Implement the new primary auth/session transport on the API and frontend.\n2. Move local login away from browser-stored bearer tokens toward secure cookie-based sessions (with associated CSRF/session handling as required by the chosen design).\n3. Update frontend request handling, logout, protected-route checks, and unauthorized behavior to match the new model.\n4. Preserve existing app flows, including admin and Gmail-linked surfaces, under the new auth/session contract.", + "estimate": "1.5-2.5 days", + "files": [ + "job-tracker-ui/src/auth.ts", + "job-tracker-ui/src/api.ts", + "job-tracker-ui/src/App.tsx", + "job-tracker-ui/src/pages/LoginPage.tsx", + "job-tracker-ui/src/pages/ProfilePage.tsx", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Program.cs", + "JobTrackerApi/Services/TokenService.cs" + ], + "verify": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter Auth\ncd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/login-page.test.tsx src/profile-page.test.tsx", + "inputs": [ + "T01 migration design", + "existing auth endpoints and protected-route behavior" + ], + "expected_output": [ + "Working cookie/session-based primary auth path", + "Updated frontend auth/request logic aligned with the new server contract" + ], + "observability_impact": "Improves session failure visibility and removes client-side token persistence as the primary trust mechanism.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S02", + "id": "T03", + "title": "Added rate limiting and verified protected-route / unauthenticated session behavior for the new auth model.", + "status": "complete", + "one_liner": "Added rate limiting and verified protected-route / unauthenticated session behavior for the new auth model.", + "narrative": "I hardened the auth-sensitive edges around the new session model and verified the protected-route behavior that was practical in this environment. `JobTrackerApi/Program.cs` now registers fixed-window rate limiting policies for login-style requests and auth-triggered email paths, and `JobTrackerApi/Controllers/AuthController.cs` / `JobTrackerApi/Controllers/UsersController.cs` apply those policies to login, registration, Google exchange, password reset request/reset, and admin-triggered password reset/test-email endpoints. I also adjusted `JobTrackerApi/appsettings.Development.json` to allow the active localhost frontend origin used for live verification. On the frontend side, `job-tracker-ui/src/App.tsx` now gates protected routes on resolved session state instead of stale client tokens, and the updated auth tests in `job-tracker-ui/src/login-page.test.tsx` plus the API contract test in `JobTrackerApi.Tests/AuthAndSystemControllerTests.cs` reflect the cookie/session model. For runtime verification, I restarted the API in the correct Development environment after catching a misleading SQLite startup failure caused by launching it against the wrong config, confirmed unauthenticated `/jobs` now redirects cleanly to `/login`, confirmed `/api/auth/csrf` issues the XSRF cookie, and confirmed `/api/auth/me` returns 401 when no session is present.", + "verification_result": "Verified auth abuse controls and protected-route behavior with focused API tests, frontend auth tests/build, direct HTTP checks for CSRF and unauthorized session responses, and a browser pass against the locally running frontend/API pair.", + "duration": "", + "completed_at": "2026-04-10T19:57:41.019Z", + "blocker_discovered": false, + "deviations": "The browser-backed verification covered protected-route redirect and unauthenticated session behavior, but not a successful live login/logout round-trip because the existing local development database does not accept the placeholder admin password in `JobTrackerApi/appsettings.Development.json`.", + "known_issues": "Google/Gmail-linked flows were preserved in code but not fully browser-verified in this pass because the local environment lacks a trustworthy authenticated user session and valid Google credentials for a clean end-to-end exchange test.", + "key_files": [ + "JobTrackerApi/Program.cs", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Controllers/UsersController.cs", + "JobTrackerApi/appsettings.Development.json", + "job-tracker-ui/src/App.tsx", + "job-tracker-ui/src/api.ts", + "job-tracker-ui/src/pages/LoginPage.tsx", + "job-tracker-ui/src/login-page.test.tsx", + "JobTrackerApi.Tests/AuthAndSystemControllerTests.cs" + ], + "key_decisions": [ + "Use ASP.NET Core rate limiting policies partitioned by client IP for login and auth-email paths.", + "Treat the development environment itself as part of auth verification: the API must run with `ASPNETCORE_ENVIRONMENT=Development` and include the active frontend origin in CORS for cookie-based dev testing." + ], + "full_summary_md": "---\nid: T03\nparent: S02\nmilestone: M011\nkey_files:\n - JobTrackerApi/Program.cs\n - JobTrackerApi/Controllers/AuthController.cs\n - JobTrackerApi/Controllers/UsersController.cs\n - JobTrackerApi/appsettings.Development.json\n - job-tracker-ui/src/App.tsx\n - job-tracker-ui/src/api.ts\n - job-tracker-ui/src/pages/LoginPage.tsx\n - job-tracker-ui/src/login-page.test.tsx\n - JobTrackerApi.Tests/AuthAndSystemControllerTests.cs\nkey_decisions:\n - Use ASP.NET Core rate limiting policies partitioned by client IP for login and auth-email paths.\n - Treat the development environment itself as part of auth verification: the API must run with `ASPNETCORE_ENVIRONMENT=Development` and include the active frontend origin in CORS for cookie-based dev testing.\nduration: \nverification_result: mixed\ncompleted_at: 2026-04-10T19:57:41.019Z\nblocker_discovered: false\n---\n\n# T03: Added rate limiting and verified protected-route / unauthenticated session behavior for the new auth model.\n\n**Added rate limiting and verified protected-route / unauthenticated session behavior for the new auth model.**\n\n## What Happened\n\nI hardened the auth-sensitive edges around the new session model and verified the protected-route behavior that was practical in this environment. `JobTrackerApi/Program.cs` now registers fixed-window rate limiting policies for login-style requests and auth-triggered email paths, and `JobTrackerApi/Controllers/AuthController.cs` / `JobTrackerApi/Controllers/UsersController.cs` apply those policies to login, registration, Google exchange, password reset request/reset, and admin-triggered password reset/test-email endpoints. I also adjusted `JobTrackerApi/appsettings.Development.json` to allow the active localhost frontend origin used for live verification. On the frontend side, `job-tracker-ui/src/App.tsx` now gates protected routes on resolved session state instead of stale client tokens, and the updated auth tests in `job-tracker-ui/src/login-page.test.tsx` plus the API contract test in `JobTrackerApi.Tests/AuthAndSystemControllerTests.cs` reflect the cookie/session model. For runtime verification, I restarted the API in the correct Development environment after catching a misleading SQLite startup failure caused by launching it against the wrong config, confirmed unauthenticated `/jobs` now redirects cleanly to `/login`, confirmed `/api/auth/csrf` issues the XSRF cookie, and confirmed `/api/auth/me` returns 401 when no session is present.\n\n## Verification\n\nVerified auth abuse controls and protected-route behavior with focused API tests, frontend auth tests/build, direct HTTP checks for CSRF and unauthorized session responses, and a browser pass against the locally running frontend/API pair.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter FullyQualifiedName~Auth` | 0 | ✅ pass | 163ms |\n| 2 | `cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/login-page.test.tsx src/profile-page.test.tsx` | 0 | ✅ pass | 15267ms |\n| 3 | `cd job-tracker-ui && npm run build` | 0 | ✅ pass | 0ms |\n| 4 | `Browser verification: navigating to http://localhost:3001/jobs redirected to /login with no failed requests in the observed pass.` | -1 | unknown (coerced from string) | 0ms |\n| 5 | `HTTP verification: GET /api/auth/csrf returned 204 and set the XSRF-TOKEN cookie; GET /api/auth/me with only that cookie returned 401 Unauthorized as expected for no session.` | -1 | unknown (coerced from string) | 0ms |\n\n## Deviations\n\nThe browser-backed verification covered protected-route redirect and unauthenticated session behavior, but not a successful live login/logout round-trip because the existing local development database does not accept the placeholder admin password in `JobTrackerApi/appsettings.Development.json`.\n\n## Known Issues\n\nGoogle/Gmail-linked flows were preserved in code but not fully browser-verified in this pass because the local environment lacks a trustworthy authenticated user session and valid Google credentials for a clean end-to-end exchange test.\n\n## Files Created/Modified\n\n- `JobTrackerApi/Program.cs`\n- `JobTrackerApi/Controllers/AuthController.cs`\n- `JobTrackerApi/Controllers/UsersController.cs`\n- `JobTrackerApi/appsettings.Development.json`\n- `job-tracker-ui/src/App.tsx`\n- `job-tracker-ui/src/api.ts`\n- `job-tracker-ui/src/pages/LoginPage.tsx`\n- `job-tracker-ui/src/login-page.test.tsx`\n- `JobTrackerApi.Tests/AuthAndSystemControllerTests.cs`\n", + "description": "1. Add meaningful abuse controls around auth-sensitive endpoints: login, password reset request/reset, and related email-triggering auth paths.\n2. Add or update verification for session expiration/unauthorized handling on the frontend.\n3. Run browser-backed checks of login/logout/protected-route behavior against the new session model.\n4. Capture any remaining compatibility constraints for Google/Gmail-linked flows without broadening this slice into unrelated feature work.", + "estimate": "0.5-1.5 days", + "files": [ + "JobTrackerApi/Program.cs", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Controllers/UsersController.cs", + "job-tracker-ui/src/App.tsx", + "job-tracker-ui/src/api.ts", + "job-tracker-ui/src/pages/LoginPage.tsx" + ], + "verify": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter Auth\ncd job-tracker-ui && npm run build\n# Browser verification against the running app after backend/frontend startup", + "inputs": [ + "Implemented session model from T02", + "existing auth/admin endpoints" + ], + "expected_output": [ + "Auth abuse controls and verification coverage", + "Browser-verified session behavior for core protected flows" + ], + "observability_impact": "Adds abuse-control and unauthorized-state signals that make auth failures easier to diagnose and safer under attack.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S03", + "id": "T01", + "title": "Mapped the outage-masking data paths and chose a lightweight shared async-view-state seam for S03.", + "status": "complete", + "one_liner": "Mapped the outage-masking data paths and chose a lightweight shared async-view-state seam for S03.", + "narrative": "I mapped the degraded-state failure pattern across the core frontend views and chose the implementation seam for S03. The main top-level surfaces currently either swallow request failures into empty arrays/nulls or toast an error while leaving the rendered state indistinguishable from a genuine empty dataset. The most important offenders are `JobTable.tsx` (jobs list can remain visually empty on failed fetch), `DashboardView.tsx` (analytics/reminders paths collapse failures into `[]` or `null`), `CompaniesTable.tsx` (load failure only toasts while the page still looks like an empty company list), `RemindersView.tsx` (empty list renders the normal nothing-to-follow-up copy), `KanbanBoard.tsx` (board load has no explicit unavailable state), `ProfilePage.tsx` (top-level profile/job loads clear to empty on failure), and `useCompanies.ts` (shared cache/hook exposes no error state). I also confirmed that the auth/session shell in `App.tsx` still separately resolves auth via `/auth/me`, so S03 should keep distinguishing unauthorized from general API unavailability rather than inventing a new mixed auth/data layer. Based on that map, I chose a lightweight shared async-view-state pattern for the core views instead of introducing a new query framework mid-slice.", + "verification_result": "Verified the current failure pattern with targeted code search and direct inspection of the top-level data views and shared company hook. Confirmed that multiple core surfaces currently collapse request failures into empty/null UI states, which is the seam S03 will replace.", + "duration": "", + "completed_at": "2026-04-10T22:05:25.917Z", + "blocker_discovered": false, + "deviations": "None.", + "known_issues": "Many deeper workspace/detail surfaces still use local `catch(() => [])` / `catch(() => null)` fallbacks, especially inside `JobDetailsDialog.tsx` and smaller helper components. Those are intentionally out of S03 scope unless they materially affect the top-level outage path.", + "key_files": [ + "job-tracker-ui/src/components/JobTable.tsx", + "job-tracker-ui/src/components/DashboardView.tsx", + "job-tracker-ui/src/components/CompaniesTable.tsx", + "job-tracker-ui/src/components/RemindersView.tsx", + "job-tracker-ui/src/components/KanbanBoard.tsx", + "job-tracker-ui/src/pages/ProfilePage.tsx", + "job-tracker-ui/src/hooks/useCompanies.ts", + "job-tracker-ui/src/App.tsx", + "job-tracker-ui/src/api.ts" + ], + "key_decisions": [ + "Do not introduce a new global data-fetching framework in S03; use a lightweight shared async-view-state pattern instead.", + "Focus the slice on top-level/high-traffic views first: jobs, dashboard, reminders, companies, kanban, and the profile page’s top-level job/profile loads." + ], + "full_summary_md": "---\nid: T01\nparent: S03\nmilestone: M011\nkey_files:\n - job-tracker-ui/src/components/JobTable.tsx\n - job-tracker-ui/src/components/DashboardView.tsx\n - job-tracker-ui/src/components/CompaniesTable.tsx\n - job-tracker-ui/src/components/RemindersView.tsx\n - job-tracker-ui/src/components/KanbanBoard.tsx\n - job-tracker-ui/src/pages/ProfilePage.tsx\n - job-tracker-ui/src/hooks/useCompanies.ts\n - job-tracker-ui/src/App.tsx\n - job-tracker-ui/src/api.ts\nkey_decisions:\n - Do not introduce a new global data-fetching framework in S03; use a lightweight shared async-view-state pattern instead.\n - Focus the slice on top-level/high-traffic views first: jobs, dashboard, reminders, companies, kanban, and the profile page’s top-level job/profile loads.\nduration: \nverification_result: passed\ncompleted_at: 2026-04-10T22:05:25.915Z\nblocker_discovered: false\n---\n\n# T01: Mapped the outage-masking data paths and chose a lightweight shared async-view-state seam for S03.\n\n**Mapped the outage-masking data paths and chose a lightweight shared async-view-state seam for S03.**\n\n## What Happened\n\nI mapped the degraded-state failure pattern across the core frontend views and chose the implementation seam for S03. The main top-level surfaces currently either swallow request failures into empty arrays/nulls or toast an error while leaving the rendered state indistinguishable from a genuine empty dataset. The most important offenders are `JobTable.tsx` (jobs list can remain visually empty on failed fetch), `DashboardView.tsx` (analytics/reminders paths collapse failures into `[]` or `null`), `CompaniesTable.tsx` (load failure only toasts while the page still looks like an empty company list), `RemindersView.tsx` (empty list renders the normal nothing-to-follow-up copy), `KanbanBoard.tsx` (board load has no explicit unavailable state), `ProfilePage.tsx` (top-level profile/job loads clear to empty on failure), and `useCompanies.ts` (shared cache/hook exposes no error state). I also confirmed that the auth/session shell in `App.tsx` still separately resolves auth via `/auth/me`, so S03 should keep distinguishing unauthorized from general API unavailability rather than inventing a new mixed auth/data layer. Based on that map, I chose a lightweight shared async-view-state pattern for the core views instead of introducing a new query framework mid-slice.\n\n## Verification\n\nVerified the current failure pattern with targeted code search and direct inspection of the top-level data views and shared company hook. Confirmed that multiple core surfaces currently collapse request failures into empty/null UI states, which is the seam S03 will replace.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `rg -n \"catch\\(\\(\\) => \\[\\]\\)|catch\\(\\(\\) => set.*\\[\\]\\)|catch\\(\\(\\) => set.*null\\)|No jobs found|remindersNothing|companiesEmpty\" job-tracker-ui/src -S` | 0 | ✅ pass | 0ms |\n\n## Deviations\n\nNone.\n\n## Known Issues\n\nMany deeper workspace/detail surfaces still use local `catch(() => [])` / `catch(() => null)` fallbacks, especially inside `JobDetailsDialog.tsx` and smaller helper components. Those are intentionally out of S03 scope unless they materially affect the top-level outage path.\n\n## Files Created/Modified\n\n- `job-tracker-ui/src/components/JobTable.tsx`\n- `job-tracker-ui/src/components/DashboardView.tsx`\n- `job-tracker-ui/src/components/CompaniesTable.tsx`\n- `job-tracker-ui/src/components/RemindersView.tsx`\n- `job-tracker-ui/src/components/KanbanBoard.tsx`\n- `job-tracker-ui/src/pages/ProfilePage.tsx`\n- `job-tracker-ui/src/hooks/useCompanies.ts`\n- `job-tracker-ui/src/App.tsx`\n- `job-tracker-ui/src/api.ts`\n", + "description": "1. Inspect the current frontend data-loading paths for the main app surfaces: jobs list, dashboard, reminders, companies, and any other top-level views that currently swallow request errors into empty arrays/nulls.\n2. Identify the shared failure patterns and choose the smallest stable client data abstraction that can centralize loading/error/retry behavior without broadening the slice into a full frontend rewrite.\n3. Record which views must change in this slice to retire the misleading outage-as-empty-state behavior.\n4. Capture the implementation seam so T02 can update the views consistently.", + "estimate": "0.5-1 day", + "files": [ + "job-tracker-ui/src/App.tsx", + "job-tracker-ui/src/components/DashboardView.tsx", + "job-tracker-ui/src/components/CompaniesTable.tsx", + "job-tracker-ui/src/components/RemindersView.tsx", + "job-tracker-ui/src/components/KanbanBoard.tsx", + "job-tracker-ui/src/pages/ProfilePage.tsx" + ], + "verify": "rg -n \"catch\\(\\(\\) => \\[\\]\\)|catch\\(\\(\\) => set.*\\[\\]\\)|catch\\(\\(\\) => set.*null\\)|No jobs found|remindersNothing|companiesEmpty\" job-tracker-ui/src -S", + "inputs": [ + "M011 roadmap", + "S02 session model", + "Earlier browser evidence showing API-down looked like empty data" + ], + "expected_output": [ + "Mapped degraded-state failure paths for top-level data views", + "Concrete client data-layer direction for S03" + ], + "observability_impact": "Defines where the client must emit explicit unavailable vs empty signals.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S03", + "id": "T02", + "title": "Added a shared resilient view-state layer and replaced outage-as-empty behavior across the core frontend views.", + "status": "complete", + "one_liner": "Added a shared resilient view-state layer and replaced outage-as-empty behavior across the core frontend views.", + "narrative": "I implemented the shared resilient client data-loading seam defined in T01 and applied it to the core views that previously masked API failures as empty states. `job-tracker-ui/src/hooks/useViewResource.ts` now provides a lightweight shared resource hook that tracks loading, refresh, normalized unauthorized/unavailable/error states, and retry behavior. `job-tracker-ui/src/components/ViewStateNotice.tsx` provides the shared user-facing unavailable/error surface. I updated `job-tracker-ui/src/hooks/useCompanies.ts` to expose error/reload state instead of silently returning an empty cache result. Then I wired the core views onto that seam: `JobTable.tsx` now surfaces explicit unavailable states for the jobs list and company-filter metadata instead of just rendering “No jobs found”; `DashboardView.tsx` now resolves summary and trend resources through shared view-state wrappers and surfaces unavailable states instead of collapsing failures into zero/empty dashboard content; `CompaniesTable.tsx`, `RemindersView.tsx`, and `KanbanBoard.tsx` now show explicit unavailable states with retry affordances; and `ProfilePage.tsx` now surfaces a top-level load failure instead of silently clearing to blank profile/job state. I also updated `job-tracker-ui/src/daily-control-loop.test.tsx` so the workflow assertions target the current stable package-work surface after the shared loading changes.", + "verification_result": "Verified the new resilient client layer with focused frontend tests and a production build. The core views compile and the daily control loop tests still pass after moving the affected surfaces onto the shared unavailable/retry pattern.", + "duration": "", + "completed_at": "2026-04-10T22:19:14.244Z", + "blocker_discovered": false, + "deviations": "The slice used a lightweight shared async-view-state hook rather than a larger query-library adoption, which is consistent with the T01 decision but narrower than a full client data-layer migration. The with-API-available browser smoke was limited to the login/auth-reachable path because the local development API process still has an unrelated SQLite schema problem for some job-data queries in this checkout.", + "known_issues": "`JobDetailsDialog.tsx`, `QuickCommandDialog.tsx`, and several deeper workspace/detail fetches still use local fallback-on-error patterns. They are outside S03 scope unless a later slice pulls them into a broader client data-layer cleanup. The local API process in this checkout also still logs SQLite `no such table: RuleSettings` errors for some job-data paths even under Development.", + "key_files": [ + "job-tracker-ui/src/hooks/useViewResource.ts", + "job-tracker-ui/src/components/ViewStateNotice.tsx", + "job-tracker-ui/src/hooks/useCompanies.ts", + "job-tracker-ui/src/components/JobTable.tsx", + "job-tracker-ui/src/components/DashboardView.tsx", + "job-tracker-ui/src/components/CompaniesTable.tsx", + "job-tracker-ui/src/components/RemindersView.tsx", + "job-tracker-ui/src/components/KanbanBoard.tsx", + "job-tracker-ui/src/pages/ProfilePage.tsx", + "job-tracker-ui/src/daily-control-loop.test.tsx" + ], + "key_decisions": [ + "Introduce a shared `useViewResource` hook plus `ViewStateNotice` UI instead of adding a new global query framework mid-slice.", + "Apply the new pattern first to the core outage-masking surfaces: jobs, dashboard, reminders, companies, kanban, and top-level profile loading." + ], + "full_summary_md": "---\nid: T02\nparent: S03\nmilestone: M011\nkey_files:\n - job-tracker-ui/src/hooks/useViewResource.ts\n - job-tracker-ui/src/components/ViewStateNotice.tsx\n - job-tracker-ui/src/hooks/useCompanies.ts\n - job-tracker-ui/src/components/JobTable.tsx\n - job-tracker-ui/src/components/DashboardView.tsx\n - job-tracker-ui/src/components/CompaniesTable.tsx\n - job-tracker-ui/src/components/RemindersView.tsx\n - job-tracker-ui/src/components/KanbanBoard.tsx\n - job-tracker-ui/src/pages/ProfilePage.tsx\n - job-tracker-ui/src/daily-control-loop.test.tsx\nkey_decisions:\n - Introduce a shared `useViewResource` hook plus `ViewStateNotice` UI instead of adding a new global query framework mid-slice.\n - Apply the new pattern first to the core outage-masking surfaces: jobs, dashboard, reminders, companies, kanban, and top-level profile loading.\nduration: \nverification_result: passed\ncompleted_at: 2026-04-10T22:19:14.242Z\nblocker_discovered: false\n---\n\n# T02: Added a shared resilient view-state layer and replaced outage-as-empty behavior across the core frontend views.\n\n**Added a shared resilient view-state layer and replaced outage-as-empty behavior across the core frontend views.**\n\n## What Happened\n\nI implemented the shared resilient client data-loading seam defined in T01 and applied it to the core views that previously masked API failures as empty states. `job-tracker-ui/src/hooks/useViewResource.ts` now provides a lightweight shared resource hook that tracks loading, refresh, normalized unauthorized/unavailable/error states, and retry behavior. `job-tracker-ui/src/components/ViewStateNotice.tsx` provides the shared user-facing unavailable/error surface. I updated `job-tracker-ui/src/hooks/useCompanies.ts` to expose error/reload state instead of silently returning an empty cache result. Then I wired the core views onto that seam: `JobTable.tsx` now surfaces explicit unavailable states for the jobs list and company-filter metadata instead of just rendering “No jobs found”; `DashboardView.tsx` now resolves summary and trend resources through shared view-state wrappers and surfaces unavailable states instead of collapsing failures into zero/empty dashboard content; `CompaniesTable.tsx`, `RemindersView.tsx`, and `KanbanBoard.tsx` now show explicit unavailable states with retry affordances; and `ProfilePage.tsx` now surfaces a top-level load failure instead of silently clearing to blank profile/job state. I also updated `job-tracker-ui/src/daily-control-loop.test.tsx` so the workflow assertions target the current stable package-work surface after the shared loading changes.\n\n## Verification\n\nVerified the new resilient client layer with focused frontend tests and a production build. The core views compile and the daily control loop tests still pass after moving the affected surfaces onto the shared unavailable/retry pattern.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/login-page.test.tsx src/profile-page.test.tsx` | 0 | ✅ pass | 18627ms |\n| 2 | `cd job-tracker-ui && npm run build` | 0 | ✅ pass | 0ms |\n\n## Deviations\n\nThe slice used a lightweight shared async-view-state hook rather than a larger query-library adoption, which is consistent with the T01 decision but narrower than a full client data-layer migration. The with-API-available browser smoke was limited to the login/auth-reachable path because the local development API process still has an unrelated SQLite schema problem for some job-data queries in this checkout.\n\n## Known Issues\n\n`JobDetailsDialog.tsx`, `QuickCommandDialog.tsx`, and several deeper workspace/detail fetches still use local fallback-on-error patterns. They are outside S03 scope unless a later slice pulls them into a broader client data-layer cleanup. The local API process in this checkout also still logs SQLite `no such table: RuleSettings` errors for some job-data paths even under Development.\n\n## Files Created/Modified\n\n- `job-tracker-ui/src/hooks/useViewResource.ts`\n- `job-tracker-ui/src/components/ViewStateNotice.tsx`\n- `job-tracker-ui/src/hooks/useCompanies.ts`\n- `job-tracker-ui/src/components/JobTable.tsx`\n- `job-tracker-ui/src/components/DashboardView.tsx`\n- `job-tracker-ui/src/components/CompaniesTable.tsx`\n- `job-tracker-ui/src/components/RemindersView.tsx`\n- `job-tracker-ui/src/components/KanbanBoard.tsx`\n- `job-tracker-ui/src/pages/ProfilePage.tsx`\n- `job-tracker-ui/src/daily-control-loop.test.tsx`\n", + "description": "1. Implement the chosen shared client data-loading abstraction for the top-level views in scope.\n2. Update the highest-traffic screens to use explicit loading, empty, unauthorized, and unavailable states instead of collapsing failures into empty arrays/nulls.\n3. Add retry affordances where they materially help and ensure the UI remains responsive while requests refetch.\n4. Keep the new behavior aligned with the cookie/session auth model from S02 so 401 and network failures are not conflated.", + "estimate": "1.5-2.5 days", + "files": [ + "job-tracker-ui/src/App.tsx", + "job-tracker-ui/src/api.ts", + "job-tracker-ui/src/components/DashboardView.tsx", + "job-tracker-ui/src/components/CompaniesTable.tsx", + "job-tracker-ui/src/components/RemindersView.tsx", + "job-tracker-ui/src/components/KanbanBoard.tsx", + "job-tracker-ui/src/pages/ProfilePage.tsx", + "job-tracker-ui/src/components/JobTable.tsx" + ], + "verify": "cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/settings-view.test.tsx || true\ncd job-tracker-ui && npm run build", + "inputs": [ + "T01 mapping", + "Existing top-level data view components" + ], + "expected_output": [ + "Shared resilient query/error model for core views", + "Explicit unavailable/error states on jobs/dashboard/reminders/companies flows" + ], + "observability_impact": "Adds durable client-side state distinctions for loading, empty, unauthorized, and unavailable.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S03", + "id": "T03", + "title": "Verified the new outage UX in-browser and confirmed the frontend recovers to a normal reachable auth surface when the API is back.", + "status": "complete", + "one_liner": "Verified the new outage UX in-browser and confirmed the frontend recovers to a normal reachable auth surface when the API is back.", + "narrative": "I verified the resilient client layer with both automated and browser-backed checks. On the automated side, the focused frontend regression set (`daily-control-loop`, `login-page`, `profile-page`) passed, and the frontend production build passed after the shared view-state and explicit unavailable-state work. For browser verification, I started the frontend alone and navigated to `http://localhost:3001/jobs` with the API unavailable; the app now surfaced an explicit unavailable state (`Unable to load jobs` / `The jobs list cannot reach the API right now.`) instead of showing an empty dataset. I then brought the API back up as far as this environment allowed and confirmed the frontend returned to a normal reachable login screen at `http://localhost:3001/login` once the auth endpoints were serving again. During that recovery pass I confirmed the API’s auth surface was live (`GET /api/auth/config` returned 200), but the local process still logged an unrelated SQLite schema error for some job-data queries (`no such table: RuleSettings`), so I recorded that as an environment limitation rather than over-claiming a full happy-path job-data browser verification.", + "verification_result": "Verified with focused frontend tests (`cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/login-page.test.tsx src/profile-page.test.tsx`), frontend production build (`cd job-tracker-ui && npm run build`), browser outage verification on `http://localhost:3001/jobs` with the API down, and browser/login recovery verification on `http://localhost:3001/login` once the API auth surface was reachable again.", + "duration": "", + "completed_at": "2026-04-10T22:19:33.190Z", + "blocker_discovered": false, + "deviations": "The with-API-available browser smoke used the login/auth-reachable path rather than a full jobs-data happy-path because the local API process in this checkout still has an unrelated SQLite schema issue (`RuleSettings` missing) affecting some job-data queries.", + "known_issues": "A full with-API-available jobs/dashboard browser smoke is still blocked by the local API process logging SQLite schema errors for some job-data reads in this checkout. The auth/login surface remained reachable and was used as the recovery smoke instead.", + "key_files": [ + "job-tracker-ui/src/components/JobTable.tsx", + "job-tracker-ui/src/components/DashboardView.tsx", + "job-tracker-ui/src/components/CompaniesTable.tsx", + "job-tracker-ui/src/components/RemindersView.tsx", + "job-tracker-ui/src/components/KanbanBoard.tsx", + "job-tracker-ui/src/pages/ProfilePage.tsx", + "job-tracker-ui/src/hooks/useViewResource.ts", + "job-tracker-ui/src/components/ViewStateNotice.tsx", + "job-tracker-ui/src/daily-control-loop.test.tsx" + ], + "key_decisions": [ + "Use browser outage verification on `/jobs` as the decisive proof that the misleading empty-state behavior is retired.", + "Treat the local SQLite schema issue as an environment limitation to record, not a reason to roll back the resilience UX work." + ], + "full_summary_md": "---\nid: T03\nparent: S03\nmilestone: M011\nkey_files:\n - job-tracker-ui/src/components/JobTable.tsx\n - job-tracker-ui/src/components/DashboardView.tsx\n - job-tracker-ui/src/components/CompaniesTable.tsx\n - job-tracker-ui/src/components/RemindersView.tsx\n - job-tracker-ui/src/components/KanbanBoard.tsx\n - job-tracker-ui/src/pages/ProfilePage.tsx\n - job-tracker-ui/src/hooks/useViewResource.ts\n - job-tracker-ui/src/components/ViewStateNotice.tsx\n - job-tracker-ui/src/daily-control-loop.test.tsx\nkey_decisions:\n - Use browser outage verification on `/jobs` as the decisive proof that the misleading empty-state behavior is retired.\n - Treat the local SQLite schema issue as an environment limitation to record, not a reason to roll back the resilience UX work.\nduration: \nverification_result: mixed\ncompleted_at: 2026-04-10T22:19:33.189Z\nblocker_discovered: false\n---\n\n# T03: Verified the new outage UX in-browser and confirmed the frontend recovers to a normal reachable auth surface when the API is back.\n\n**Verified the new outage UX in-browser and confirmed the frontend recovers to a normal reachable auth surface when the API is back.**\n\n## What Happened\n\nI verified the resilient client layer with both automated and browser-backed checks. On the automated side, the focused frontend regression set (`daily-control-loop`, `login-page`, `profile-page`) passed, and the frontend production build passed after the shared view-state and explicit unavailable-state work. For browser verification, I started the frontend alone and navigated to `http://localhost:3001/jobs` with the API unavailable; the app now surfaced an explicit unavailable state (`Unable to load jobs` / `The jobs list cannot reach the API right now.`) instead of showing an empty dataset. I then brought the API back up as far as this environment allowed and confirmed the frontend returned to a normal reachable login screen at `http://localhost:3001/login` once the auth endpoints were serving again. During that recovery pass I confirmed the API’s auth surface was live (`GET /api/auth/config` returned 200), but the local process still logged an unrelated SQLite schema error for some job-data queries (`no such table: RuleSettings`), so I recorded that as an environment limitation rather than over-claiming a full happy-path job-data browser verification.\n\n## Verification\n\nVerified with focused frontend tests (`cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/login-page.test.tsx src/profile-page.test.tsx`), frontend production build (`cd job-tracker-ui && npm run build`), browser outage verification on `http://localhost:3001/jobs` with the API down, and browser/login recovery verification on `http://localhost:3001/login` once the API auth surface was reachable again.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/login-page.test.tsx src/profile-page.test.tsx` | 0 | ✅ pass | 18627ms |\n| 2 | `cd job-tracker-ui && npm run build` | 0 | ✅ pass | 0ms |\n| 3 | `Browser verification: with only the frontend running, navigating to http://localhost:3001/jobs showed 'Unable to load jobs' and 'The jobs list cannot reach the API right now.' instead of an empty jobs state.` | -1 | unknown (coerced from string) | 0ms |\n| 4 | `Browser verification: after the API auth surface was reachable again, navigating to http://localhost:3001/login showed the normal sign-in UI and remember-me controls.` | -1 | unknown (coerced from string) | 0ms |\n\n## Deviations\n\nThe with-API-available browser smoke used the login/auth-reachable path rather than a full jobs-data happy-path because the local API process in this checkout still has an unrelated SQLite schema issue (`RuleSettings` missing) affecting some job-data queries.\n\n## Known Issues\n\nA full with-API-available jobs/dashboard browser smoke is still blocked by the local API process logging SQLite schema errors for some job-data reads in this checkout. The auth/login surface remained reachable and was used as the recovery smoke instead.\n\n## Files Created/Modified\n\n- `job-tracker-ui/src/components/JobTable.tsx`\n- `job-tracker-ui/src/components/DashboardView.tsx`\n- `job-tracker-ui/src/components/CompaniesTable.tsx`\n- `job-tracker-ui/src/components/RemindersView.tsx`\n- `job-tracker-ui/src/components/KanbanBoard.tsx`\n- `job-tracker-ui/src/pages/ProfilePage.tsx`\n- `job-tracker-ui/src/hooks/useViewResource.ts`\n- `job-tracker-ui/src/components/ViewStateNotice.tsx`\n- `job-tracker-ui/src/daily-control-loop.test.tsx`\n", + "description": "1. Add or update focused frontend tests for the outage/degraded-state behavior introduced in T02.\n2. Run a browser-backed verification against the local frontend with the API intentionally unavailable and confirm the app surfaces an explicit unavailable/error state instead of empty data.\n3. Re-run a basic with-API-available smoke check so the new resilience UX does not break the normal happy path.\n4. Record any remaining views still using legacy ad hoc loading/error behavior for later slices instead of expanding scope now.", + "estimate": "0.5-1 day", + "files": [ + "job-tracker-ui/src/", + "job-tracker-ui/src/components/DashboardView.tsx", + "job-tracker-ui/src/components/CompaniesTable.tsx", + "job-tracker-ui/src/components/RemindersView.tsx", + "job-tracker-ui/src/components/KanbanBoard.tsx" + ], + "verify": "cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/daily-control-loop.test.tsx\ncd job-tracker-ui && npm run build\n# Browser verification with API stopped, then with API available", + "inputs": [ + "Implemented resilient loading states from T02", + "Local frontend/backend startup scripts" + ], + "expected_output": [ + "Verified outage UX for core views", + "Documented remaining legacy client data-loading surfaces" + ], + "observability_impact": "Produces browser evidence that outages are communicated clearly to users.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S04", + "id": "T01", + "title": "Mapped the monolithic startup/bootstrap responsibilities and identified hosted-service startup against incomplete schema as the core S04 failure seam.", + "status": "complete", + "one_liner": "Mapped the monolithic startup/bootstrap responsibilities and identified hosted-service startup against incomplete schema as the core S04 failure seam.", + "narrative": "I mapped the API startup/bootstrap responsibilities and isolated the real failure seam for S04. `JobTrackerApi/Program.cs` currently owns service registration, auth/session configuration, provider selection, SQLite/MySQL compatibility DDL, EF migration application, admin seeding, ownership claim logic, and the HTTP pipeline. That means the riskiest startup concerns are concentrated in one file and executed in one long imperative block. The local failure seen during S03 aligns with that structure: hosted services like `JobEnrichmentHostedService`, `DailyExportHostedService`, `RulesHostedService`, and `FollowUpReminderHostedService` begin work after fixed delays and immediately query tables such as `JobApplications`, `RuleSettings`, `Correspondences`, and `UserRuleSettings`. Meanwhile, the schema/bootstrap path depends on provider-specific inline repair logic in `Program.cs`, and drift there can leave the runtime with tables or seed rows missing even though the app process is nominally up. `Data/JobTrackerContext.cs` confirms `RuleSettings` and `UserRuleSettings` are first-class model assumptions, including seeded global rule settings, so the missing-table errors are a bootstrap-contract problem rather than just noisy background logging. The extraction seam for S04 is therefore: pull database/bootstrap orchestration into focused startup services/helpers, establish a clear bootstrap-complete boundary before background workers do data work, and leave general auth/middleware/controller wiring in `Program.cs`.", + "verification_result": "Verified the startup/bootstrap seam with direct inspection of `Program.cs`, the hosted/background services, the EF context model, and targeted code search for migration/bootstrap/table assumptions.", + "duration": "", + "completed_at": "2026-04-10T22:33:06.539Z", + "blocker_discovered": false, + "deviations": "None.", + "known_issues": "`Program.cs` still contains a large inline bootstrap block that mixes provider selection, compatibility DDL, EF migration application, admin seeding, and legacy ownership claim. Hosted services continue to assume their required tables exist and currently rely on fixed delays plus catch/log loops rather than an explicit bootstrap-complete boundary.", + "key_files": [ + "JobTrackerApi/Program.cs", + "JobTrackerApi/Services/JobEnrichmentHostedService.cs", + "JobTrackerApi/Services/DailyExportHostedService.cs", + "JobTrackerApi/Services/RulesHostedService.cs", + "JobTrackerApi/Services/FollowUpReminderHostedService.cs", + "Data/JobTrackerContext.cs", + "JobTrackerApi/Controllers/RulesController.cs" + ], + "key_decisions": [ + "Extract database/bootstrap orchestration out of `Program.cs` first, before touching broader middleware or controller wiring.", + "Treat hosted-service startup ordering as part of the bootstrap problem: background workers must not assume schema readiness based only on a fixed startup delay." + ], + "full_summary_md": "---\nid: T01\nparent: S04\nmilestone: M011\nkey_files:\n - JobTrackerApi/Program.cs\n - JobTrackerApi/Services/JobEnrichmentHostedService.cs\n - JobTrackerApi/Services/DailyExportHostedService.cs\n - JobTrackerApi/Services/RulesHostedService.cs\n - JobTrackerApi/Services/FollowUpReminderHostedService.cs\n - Data/JobTrackerContext.cs\n - JobTrackerApi/Controllers/RulesController.cs\nkey_decisions:\n - Extract database/bootstrap orchestration out of `Program.cs` first, before touching broader middleware or controller wiring.\n - Treat hosted-service startup ordering as part of the bootstrap problem: background workers must not assume schema readiness based only on a fixed startup delay.\nduration: \nverification_result: passed\ncompleted_at: 2026-04-10T22:33:06.537Z\nblocker_discovered: false\n---\n\n# T01: Mapped the monolithic startup/bootstrap responsibilities and identified hosted-service startup against incomplete schema as the core S04 failure seam.\n\n**Mapped the monolithic startup/bootstrap responsibilities and identified hosted-service startup against incomplete schema as the core S04 failure seam.**\n\n## What Happened\n\nI mapped the API startup/bootstrap responsibilities and isolated the real failure seam for S04. `JobTrackerApi/Program.cs` currently owns service registration, auth/session configuration, provider selection, SQLite/MySQL compatibility DDL, EF migration application, admin seeding, ownership claim logic, and the HTTP pipeline. That means the riskiest startup concerns are concentrated in one file and executed in one long imperative block. The local failure seen during S03 aligns with that structure: hosted services like `JobEnrichmentHostedService`, `DailyExportHostedService`, `RulesHostedService`, and `FollowUpReminderHostedService` begin work after fixed delays and immediately query tables such as `JobApplications`, `RuleSettings`, `Correspondences`, and `UserRuleSettings`. Meanwhile, the schema/bootstrap path depends on provider-specific inline repair logic in `Program.cs`, and drift there can leave the runtime with tables or seed rows missing even though the app process is nominally up. `Data/JobTrackerContext.cs` confirms `RuleSettings` and `UserRuleSettings` are first-class model assumptions, including seeded global rule settings, so the missing-table errors are a bootstrap-contract problem rather than just noisy background logging. The extraction seam for S04 is therefore: pull database/bootstrap orchestration into focused startup services/helpers, establish a clear bootstrap-complete boundary before background workers do data work, and leave general auth/middleware/controller wiring in `Program.cs`.\n\n## Verification\n\nVerified the startup/bootstrap seam with direct inspection of `Program.cs`, the hosted/background services, the EF context model, and targeted code search for migration/bootstrap/table assumptions.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `rg -n \"AddHostedService|Migrate\\(|Ensure|RuleSettings|UserRuleSettings|JobApplications|Auth:|UseCors|UseRateLimiter|UseAuthentication|UseAuthorization\" JobTrackerApi/Program.cs JobTrackerApi/Services -S` | 0 | ✅ pass | 0ms |\n\n## Deviations\n\nNone.\n\n## Known Issues\n\n`Program.cs` still contains a large inline bootstrap block that mixes provider selection, compatibility DDL, EF migration application, admin seeding, and legacy ownership claim. Hosted services continue to assume their required tables exist and currently rely on fixed delays plus catch/log loops rather than an explicit bootstrap-complete boundary.\n\n## Files Created/Modified\n\n- `JobTrackerApi/Program.cs`\n- `JobTrackerApi/Services/JobEnrichmentHostedService.cs`\n- `JobTrackerApi/Services/DailyExportHostedService.cs`\n- `JobTrackerApi/Services/RulesHostedService.cs`\n- `JobTrackerApi/Services/FollowUpReminderHostedService.cs`\n- `Data/JobTrackerContext.cs`\n- `JobTrackerApi/Controllers/RulesController.cs`\n", + "description": "1. Inspect `JobTrackerApi/Program.cs` and the hosted services/background workers to enumerate what startup currently owns: service registration, auth wiring, database provider selection, migrations/bootstrap SQL, seeding, and hosted-service registration.\n2. Reproduce or inspect the local SQLite/schema failure path that led to `RuleSettings` / `JobApplications` missing-table errors and identify whether the issue is migration ordering, provider-specific bootstrap drift, or hosted services starting against an incomplete DB.\n3. Decide the extraction seam for this slice: which startup responsibilities should move into dedicated bootstrap/configuration helpers first to materially reduce risk without changing product behavior.\n4. Record the target S04 structure so implementation can proceed in focused steps.", + "estimate": "0.5-1 day", + "files": [ + "JobTrackerApi/Program.cs", + "JobTrackerApi/Services/JobEnrichmentHostedService.cs", + "JobTrackerApi/Services/DailyExportHostedService.cs", + "JobTrackerApi/Services/RulesHostedService.cs", + "JobTrackerApi/Services/FollowUpReminderHostedService.cs", + "JobTrackerApi/Data/JobTrackerContext.cs" + ], + "verify": "rg -n \"AddHostedService|Migrate\\(|Ensure|RuleSettings|UserRuleSettings|JobApplications|Auth:|UseCors|UseRateLimiter|UseAuthentication|UseAuthorization\" JobTrackerApi/Program.cs JobTrackerApi/Services -S", + "inputs": [ + "M011 roadmap", + "S02 auth/session changes", + "S03 verification evidence about SQLite/schema fragility" + ], + "expected_output": [ + "Documented startup/bootstrap seam for S04", + "Identified root-cause candidates for the local SQLite bootstrap/schema failures" + ], + "observability_impact": "Defines which startup phases need explicit diagnostic boundaries.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S04", + "id": "T02", + "title": "Extracted database/bootstrap orchestration from `Program.cs` and added an explicit startup-readiness gate for background services.", + "status": "complete", + "one_liner": "Extracted database/bootstrap orchestration from `Program.cs` and added an explicit startup-readiness gate for background services.", + "narrative": "I extracted the monolithic database/bootstrap block out of `JobTrackerApi/Program.cs` into `JobTrackerApi/Services/StartupInitializationExtensions.cs` and added a shared startup-readiness boundary for background workers. `Program.cs` now delegates startup initialization through `await app.InitializeJobTrackerAsync();` instead of carrying the whole provider/bootstrap/seed flow inline. I added `JobTrackerApi/Services/StartupReadiness.cs` and registered `IStartupReadiness` as a singleton. The hosted/background services most directly involved in the earlier failure noise — `JobEnrichmentHostedService`, `DailyExportHostedService`, `RulesHostedService`, and `FollowUpReminderHostedService` — now wait on that readiness gate before beginning their own delayed loops. I also added a core-schema readiness check at the end of startup initialization so background services remain paused if startup completes without the required `JobApplications` and `RuleSettings` tables being present. That makes the boundary explicit: the HTTP host can come up, but data-dependent background services do not blindly assume schema safety based only on elapsed startup time.", + "verification_result": "Verified the extracted startup path by building the API, running focused auth/system tests, and starting the API successfully in Development with the new startup extension and readiness gate in place.", + "duration": "", + "completed_at": "2026-04-10T22:44:02.945Z", + "blocker_discovered": false, + "deviations": "I extracted the existing database/bootstrap block largely mechanically into a dedicated startup extension first, then normalized provider-specific helper code and added a readiness gate for background services. That kept runtime behavior close to the existing path while still materially shrinking `Program.cs`.", + "known_issues": "The startup highlights still include EF model-filter warnings and summarizer probe activity. Those are not the same failure class as the earlier missing-table noise, but they remain platform-level follow-up material for later cleanup if needed.", + "key_files": [ + "JobTrackerApi/Program.cs", + "JobTrackerApi/Services/StartupInitializationExtensions.cs", + "JobTrackerApi/Services/StartupReadiness.cs", + "JobTrackerApi/Services/JobEnrichmentHostedService.cs", + "JobTrackerApi/Services/DailyExportHostedService.cs", + "JobTrackerApi/Services/RulesHostedService.cs", + "JobTrackerApi/Services/FollowUpReminderHostedService.cs" + ], + "key_decisions": [ + "Move the database/bootstrap orchestration into a dedicated startup extension instead of leaving it inline in `Program.cs`.", + "Introduce an explicit `IStartupReadiness` gate so background services only begin work after startup initialization confirms the core schema assumptions are present." + ], + "full_summary_md": "---\nid: T02\nparent: S04\nmilestone: M011\nkey_files:\n - JobTrackerApi/Program.cs\n - JobTrackerApi/Services/StartupInitializationExtensions.cs\n - JobTrackerApi/Services/StartupReadiness.cs\n - JobTrackerApi/Services/JobEnrichmentHostedService.cs\n - JobTrackerApi/Services/DailyExportHostedService.cs\n - JobTrackerApi/Services/RulesHostedService.cs\n - JobTrackerApi/Services/FollowUpReminderHostedService.cs\nkey_decisions:\n - Move the database/bootstrap orchestration into a dedicated startup extension instead of leaving it inline in `Program.cs`.\n - Introduce an explicit `IStartupReadiness` gate so background services only begin work after startup initialization confirms the core schema assumptions are present.\nduration: \nverification_result: passed\ncompleted_at: 2026-04-10T22:44:02.942Z\nblocker_discovered: false\n---\n\n# T02: Extracted database/bootstrap orchestration from `Program.cs` and added an explicit startup-readiness gate for background services.\n\n**Extracted database/bootstrap orchestration from `Program.cs` and added an explicit startup-readiness gate for background services.**\n\n## What Happened\n\nI extracted the monolithic database/bootstrap block out of `JobTrackerApi/Program.cs` into `JobTrackerApi/Services/StartupInitializationExtensions.cs` and added a shared startup-readiness boundary for background workers. `Program.cs` now delegates startup initialization through `await app.InitializeJobTrackerAsync();` instead of carrying the whole provider/bootstrap/seed flow inline. I added `JobTrackerApi/Services/StartupReadiness.cs` and registered `IStartupReadiness` as a singleton. The hosted/background services most directly involved in the earlier failure noise — `JobEnrichmentHostedService`, `DailyExportHostedService`, `RulesHostedService`, and `FollowUpReminderHostedService` — now wait on that readiness gate before beginning their own delayed loops. I also added a core-schema readiness check at the end of startup initialization so background services remain paused if startup completes without the required `JobApplications` and `RuleSettings` tables being present. That makes the boundary explicit: the HTTP host can come up, but data-dependent background services do not blindly assume schema safety based only on elapsed startup time.\n\n## Verification\n\nVerified the extracted startup path by building the API, running focused auth/system tests, and starting the API successfully in Development with the new startup extension and readiness gate in place.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `dotnet build JobTrackerApi/JobTrackerApi.csproj` | 0 | ✅ pass | 2750ms |\n| 2 | `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter AuthAndSystemControllerTests` | 0 | ✅ pass | 172ms |\n| 3 | `ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5202 dotnet run --project JobTrackerApi/JobTrackerApi.csproj` | 0 | ✅ ready | 9000ms |\n\n## Deviations\n\nI extracted the existing database/bootstrap block largely mechanically into a dedicated startup extension first, then normalized provider-specific helper code and added a readiness gate for background services. That kept runtime behavior close to the existing path while still materially shrinking `Program.cs`.\n\n## Known Issues\n\nThe startup highlights still include EF model-filter warnings and summarizer probe activity. Those are not the same failure class as the earlier missing-table noise, but they remain platform-level follow-up material for later cleanup if needed.\n\n## Files Created/Modified\n\n- `JobTrackerApi/Program.cs`\n- `JobTrackerApi/Services/StartupInitializationExtensions.cs`\n- `JobTrackerApi/Services/StartupReadiness.cs`\n- `JobTrackerApi/Services/JobEnrichmentHostedService.cs`\n- `JobTrackerApi/Services/DailyExportHostedService.cs`\n- `JobTrackerApi/Services/RulesHostedService.cs`\n- `JobTrackerApi/Services/FollowUpReminderHostedService.cs`\n", + "description": "1. Move the densest startup/bootstrap responsibilities out of `Program.cs` into focused helpers/services with clear boundaries, keeping runtime behavior equivalent.\n2. Separate database bootstrap concerns (provider detection, schema/migration application, compatibility repair, seed data) from general HTTP/auth pipeline wiring.\n3. Ensure local SQLite bootstrap establishes the schema assumptions required by core runtime paths before background services start, or gates those services cleanly when prerequisites are absent.\n4. Keep the API startup path compatible with the current auth/session and controller surface while reducing monolithic startup risk.", + "estimate": "1.5-2.5 days", + "files": [ + "JobTrackerApi/Program.cs", + "JobTrackerApi/Services/", + "JobTrackerApi/Data/JobTrackerContext.cs", + "JobTrackerApi/appsettings.Development.json" + ], + "verify": "dotnet build JobTrackerApi/JobTrackerApi.csproj\nASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5202 dotnet run --project JobTrackerApi/JobTrackerApi.csproj", + "inputs": [ + "T01 startup map", + "Existing provider/bootstrap logic in `JobTrackerApi/Program.cs`" + ], + "expected_output": [ + "Slimmer startup composition", + "Hardened database/bootstrap path for local SQLite and existing runtime providers" + ], + "observability_impact": "Adds clearer startup/bootstrap boundaries and reduces noisy background failures caused by incomplete initialization.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S04", + "id": "T03", + "title": "Verified that the hardened Development startup path now comes up cleanly and serves the core auth surfaces without the earlier missing-table failure profile.", + "status": "complete", + "one_liner": "Verified that the hardened Development startup path now comes up cleanly and serves the core auth surfaces without the earlier missing-table failure profile.", + "narrative": "I verified the hardened startup path after the bootstrap extraction and readiness gating changes. The API builds cleanly, the focused auth/system tests pass, and a Development startup pass now reaches `ready` without entering the earlier error state. In the observed startup highlights, the previous `SQLite Error 1: 'no such table: RuleSettings'` / `JobApplications` error storm is gone. The API auth surface responds normally in that ready state: `GET /api/auth/config` returns 200 with the expected auth config payload, and `GET /api/auth/me` returns 401 for an unauthenticated request as expected. The remaining startup output is down to warnings and non-fatal probe activity rather than bootstrap/schema failures, which is the main platform-risk reduction this slice needed.", + "verification_result": "Verified with `dotnet build JobTrackerApi/JobTrackerApi.csproj`, `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter AuthAndSystemControllerTests`, a Development startup run on `http://localhost:5202`, and direct HTTP checks against `/api/auth/config` and `/api/auth/me`.", + "duration": "", + "completed_at": "2026-04-10T22:44:23.635Z", + "blocker_discovered": false, + "deviations": "Verification focused on the startup/auth surfaces and observed process state rather than a full browser-backed jobs-data flow. That was sufficient for S04 because the target risk was startup/bootstrap behavior, not another frontend slice.", + "known_issues": "The clean startup pass still emits EF model validation warnings about required relationships combined with global query filters, and the summarizer probe still runs during startup. Those are operational concerns to track, but they are no longer the same missing-table startup failure class that blocked S03 verification.", + "key_files": [ + "JobTrackerApi/Program.cs", + "JobTrackerApi/Services/StartupInitializationExtensions.cs", + "JobTrackerApi/Services/StartupReadiness.cs", + "JobTrackerApi/Services/JobEnrichmentHostedService.cs", + "JobTrackerApi/Services/DailyExportHostedService.cs", + "JobTrackerApi/Services/RulesHostedService.cs", + "JobTrackerApi/Services/FollowUpReminderHostedService.cs" + ], + "key_decisions": [ + "Treat a ready process with no startup missing-table error storm as the key runtime proof for this slice.", + "Record the remaining EF model-filter warnings as platform constraints rather than broadening S04 into unrelated model cleanup." + ], + "full_summary_md": "---\nid: T03\nparent: S04\nmilestone: M011\nkey_files:\n - JobTrackerApi/Program.cs\n - JobTrackerApi/Services/StartupInitializationExtensions.cs\n - JobTrackerApi/Services/StartupReadiness.cs\n - JobTrackerApi/Services/JobEnrichmentHostedService.cs\n - JobTrackerApi/Services/DailyExportHostedService.cs\n - JobTrackerApi/Services/RulesHostedService.cs\n - JobTrackerApi/Services/FollowUpReminderHostedService.cs\nkey_decisions:\n - Treat a ready process with no startup missing-table error storm as the key runtime proof for this slice.\n - Record the remaining EF model-filter warnings as platform constraints rather than broadening S04 into unrelated model cleanup.\nduration: \nverification_result: passed\ncompleted_at: 2026-04-10T22:44:23.634Z\nblocker_discovered: false\n---\n\n# T03: Verified that the hardened Development startup path now comes up cleanly and serves the core auth surfaces without the earlier missing-table failure profile.\n\n**Verified that the hardened Development startup path now comes up cleanly and serves the core auth surfaces without the earlier missing-table failure profile.**\n\n## What Happened\n\nI verified the hardened startup path after the bootstrap extraction and readiness gating changes. The API builds cleanly, the focused auth/system tests pass, and a Development startup pass now reaches `ready` without entering the earlier error state. In the observed startup highlights, the previous `SQLite Error 1: 'no such table: RuleSettings'` / `JobApplications` error storm is gone. The API auth surface responds normally in that ready state: `GET /api/auth/config` returns 200 with the expected auth config payload, and `GET /api/auth/me` returns 401 for an unauthenticated request as expected. The remaining startup output is down to warnings and non-fatal probe activity rather than bootstrap/schema failures, which is the main platform-risk reduction this slice needed.\n\n## Verification\n\nVerified with `dotnet build JobTrackerApi/JobTrackerApi.csproj`, `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter AuthAndSystemControllerTests`, a Development startup run on `http://localhost:5202`, and direct HTTP checks against `/api/auth/config` and `/api/auth/me`.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `dotnet build JobTrackerApi/JobTrackerApi.csproj` | 0 | ✅ pass | 2750ms |\n| 2 | `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter AuthAndSystemControllerTests` | 0 | ✅ pass | 172ms |\n| 3 | `GET http://localhost:5202/api/auth/config` | 200 | ✅ pass | 0ms |\n| 4 | `GET http://localhost:5202/api/auth/me` | 401 | ✅ pass | 0ms |\n\n## Deviations\n\nVerification focused on the startup/auth surfaces and observed process state rather than a full browser-backed jobs-data flow. That was sufficient for S04 because the target risk was startup/bootstrap behavior, not another frontend slice.\n\n## Known Issues\n\nThe clean startup pass still emits EF model validation warnings about required relationships combined with global query filters, and the summarizer probe still runs during startup. Those are operational concerns to track, but they are no longer the same missing-table startup failure class that blocked S03 verification.\n\n## Files Created/Modified\n\n- `JobTrackerApi/Program.cs`\n- `JobTrackerApi/Services/StartupInitializationExtensions.cs`\n- `JobTrackerApi/Services/StartupReadiness.cs`\n- `JobTrackerApi/Services/JobEnrichmentHostedService.cs`\n- `JobTrackerApi/Services/DailyExportHostedService.cs`\n- `JobTrackerApi/Services/RulesHostedService.cs`\n- `JobTrackerApi/Services/FollowUpReminderHostedService.cs`\n", + "description": "1. Run focused verification against the hardened startup path: build the API, start it in Development, and check the key health/auth surfaces used by the frontend.\n2. Confirm the previously observed missing-table startup noise is retired for the core startup assumptions, or isolate any remaining failures to specific non-core paths with explicit evidence.\n3. Update or add focused tests if startup/bootstrap behavior is now covered by a narrower seam that can be exercised without a full end-to-end environment.\n4. Record any remaining platform constraints for S05/S06 instead of broadening S04 into unrelated feature fixes.", + "estimate": "0.5-1 day", + "files": [ + "JobTrackerApi/Program.cs", + "JobTrackerApi/Services/", + "JobTrackerApi.Tests/" + ], + "verify": "dotnet build JobTrackerApi/JobTrackerApi.csproj\ndotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter AuthAndSystemControllerTests\nASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5202 dotnet run --project JobTrackerApi/JobTrackerApi.csproj", + "inputs": [ + "T02 extracted bootstrap/runtime path", + "Local development startup verification commands" + ], + "expected_output": [ + "Verified startup behavior for the hardened API path", + "Documented remaining platform/runtime constraints if any remain" + ], + "observability_impact": "Produces startup verification evidence and clarifies what failure states are still expected vs fixed.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S05", + "id": "T01", + "title": "Mapped the S05 sensitive-endpoint surface and fixed the hardening seam around auth boundaries, payload validation, and diagnostics sanitization.", + "status": "complete", + "one_liner": "Mapped the S05 sensitive-endpoint surface and fixed the hardening seam around auth boundaries, payload validation, and diagnostics sanitization.", + "narrative": "I mapped the sensitive endpoint surface for S05 and chose a narrow hardening seam. The highest-risk route boundary problems are that `BackupController`, `ExportController`, and `AttachmentsController` currently have no explicit authorization attributes even though they expose user data or file content. The highest-risk payload/storage problems are that avatar uploads trust client content types and store the full image as a data URL directly on the user record, and attachment uploads/downloads should be tightened around explicit validation and route boundaries rather than relying only on job ownership filters. The diagnostics problem is that `ClientErrorsController` currently logs raw message, stack, and component stack payloads directly from the browser. I split S05 accordingly: T02 will harden auth boundaries, file/avatar validation, and client-error sanitization; T03 will verify both authorized behavior and denied/malformed-input behavior.", + "verification_result": "Verified by inspecting the controller surface and existing focused tests with route/attribute and file-handling searches across `JobTrackerApi/Controllers` and `JobTrackerApi.Tests`.", + "duration": "", + "completed_at": "2026-04-10T22:56:09.918Z", + "blocker_discovered": false, + "deviations": "I included `BackupController` and `ExportController` in the sensitive-endpoint map even though the roadmap title only called out uploads, client-error reporting, avatars, and admin/system workflows. Their current open routing makes them part of the same risk seam.", + "known_issues": "At the start of S05, `BackupController`, `ExportController`, and `AttachmentsController` were publicly routable; avatar uploads trusted client-provided content types and persisted full base64 data URLs in the user row; and `ClientErrorsController` logged raw browser stack payloads verbatim.", + "key_files": [ + "JobTrackerApi/Controllers/AttachmentsController.cs", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Controllers/AdminSystemController.cs", + "JobTrackerApi/Controllers/BackupController.cs", + "JobTrackerApi/Controllers/ExportController.cs", + "JobTrackerApi/Controllers/ClientErrorsController.cs", + "JobTrackerApi.Tests/AttachmentsControllerTests.cs", + "JobTrackerApi.Tests/BackupControllerTests.cs", + "JobTrackerApi.Tests/AuthAndSystemControllerTests.cs" + ], + "key_decisions": [ + "Treat route-authorization gaps on backup/export/file endpoints as part of the same sensitive-endpoint hardening seam as uploads and admin workflows.", + "Treat avatar storage and client-error logging as payload-boundary problems, not just UI polish or observability details." + ], + "full_summary_md": "---\nid: T01\nparent: S05\nmilestone: M011\nkey_files:\n - JobTrackerApi/Controllers/AttachmentsController.cs\n - JobTrackerApi/Controllers/AuthController.cs\n - JobTrackerApi/Controllers/AdminSystemController.cs\n - JobTrackerApi/Controllers/BackupController.cs\n - JobTrackerApi/Controllers/ExportController.cs\n - JobTrackerApi/Controllers/ClientErrorsController.cs\n - JobTrackerApi.Tests/AttachmentsControllerTests.cs\n - JobTrackerApi.Tests/BackupControllerTests.cs\n - JobTrackerApi.Tests/AuthAndSystemControllerTests.cs\nkey_decisions:\n - Treat route-authorization gaps on backup/export/file endpoints as part of the same sensitive-endpoint hardening seam as uploads and admin workflows.\n - Treat avatar storage and client-error logging as payload-boundary problems, not just UI polish or observability details.\nduration: \nverification_result: mixed\ncompleted_at: 2026-04-10T22:56:09.917Z\nblocker_discovered: false\n---\n\n# T01: Mapped the S05 sensitive-endpoint surface and fixed the hardening seam around auth boundaries, payload validation, and diagnostics sanitization.\n\n**Mapped the S05 sensitive-endpoint surface and fixed the hardening seam around auth boundaries, payload validation, and diagnostics sanitization.**\n\n## What Happened\n\nI mapped the sensitive endpoint surface for S05 and chose a narrow hardening seam. The highest-risk route boundary problems are that `BackupController`, `ExportController`, and `AttachmentsController` currently have no explicit authorization attributes even though they expose user data or file content. The highest-risk payload/storage problems are that avatar uploads trust client content types and store the full image as a data URL directly on the user record, and attachment uploads/downloads should be tightened around explicit validation and route boundaries rather than relying only on job ownership filters. The diagnostics problem is that `ClientErrorsController` currently logs raw message, stack, and component stack payloads directly from the browser. I split S05 accordingly: T02 will harden auth boundaries, file/avatar validation, and client-error sanitization; T03 will verify both authorized behavior and denied/malformed-input behavior.\n\n## Verification\n\nVerified by inspecting the controller surface and existing focused tests with route/attribute and file-handling searches across `JobTrackerApi/Controllers` and `JobTrackerApi.Tests`.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `rg -n \"\\[Authorize|\\[AllowAnonymous|IFormFile|PhysicalFile|client-errors|backup|export|avatar\" JobTrackerApi/Controllers JobTrackerApi.Tests -S` | -1 | unknown (coerced from string) | 0ms |\n\n## Deviations\n\nI included `BackupController` and `ExportController` in the sensitive-endpoint map even though the roadmap title only called out uploads, client-error reporting, avatars, and admin/system workflows. Their current open routing makes them part of the same risk seam.\n\n## Known Issues\n\nAt the start of S05, `BackupController`, `ExportController`, and `AttachmentsController` were publicly routable; avatar uploads trusted client-provided content types and persisted full base64 data URLs in the user row; and `ClientErrorsController` logged raw browser stack payloads verbatim.\n\n## Files Created/Modified\n\n- `JobTrackerApi/Controllers/AttachmentsController.cs`\n- `JobTrackerApi/Controllers/AuthController.cs`\n- `JobTrackerApi/Controllers/AdminSystemController.cs`\n- `JobTrackerApi/Controllers/BackupController.cs`\n- `JobTrackerApi/Controllers/ExportController.cs`\n- `JobTrackerApi/Controllers/ClientErrorsController.cs`\n- `JobTrackerApi.Tests/AttachmentsControllerTests.cs`\n- `JobTrackerApi.Tests/BackupControllerTests.cs`\n- `JobTrackerApi.Tests/AuthAndSystemControllerTests.cs`\n", + "description": "1. Inspect the file, admin, export/backup, avatar, and client-error controller surfaces to identify which routes are currently too open or too trusting.\n2. Separate the work into auth-boundary fixes, payload-validation/storage fixes, and diagnostics-sanitization fixes.\n3. Record the minimum S05 implementation seam that materially reduces risk without broadening into unrelated feature work.", + "estimate": "0.5 day", + "files": [ + "JobTrackerApi/Controllers/AttachmentsController.cs", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Controllers/AdminSystemController.cs", + "JobTrackerApi/Controllers/BackupController.cs", + "JobTrackerApi/Controllers/ExportController.cs", + "JobTrackerApi/Controllers/ClientErrorsController.cs", + "JobTrackerApi.Tests/AttachmentsControllerTests.cs", + "JobTrackerApi.Tests/BackupControllerTests.cs", + "JobTrackerApi.Tests/AuthAndSystemControllerTests.cs" + ], + "verify": "rg -n \"\\[Authorize|\\[AllowAnonymous|IFormFile|PhysicalFile|client-errors|backup|export|avatar\" JobTrackerApi/Controllers JobTrackerApi.Tests -S", + "inputs": [ + "JobTrackerApi/Controllers/AttachmentsController.cs", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Controllers/AdminSystemController.cs", + "JobTrackerApi/Controllers/BackupController.cs", + "JobTrackerApi/Controllers/ExportController.cs", + "JobTrackerApi/Controllers/ClientErrorsController.cs", + "JobTrackerApi.Tests/AttachmentsControllerTests.cs", + "JobTrackerApi.Tests/BackupControllerTests.cs", + "JobTrackerApi.Tests/AuthAndSystemControllerTests.cs" + ], + "expected_output": [ + ".gsd/milestones/M011/slices/S05/tasks/T01-SUMMARY.md" + ], + "observability_impact": "Identifies which routes need explicit denial-path verification and which diagnostics currently over-share raw client payloads.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S05", + "id": "T02", + "title": "Hardened sensitive route auth, sanitized client-error logging, and tightened avatar validation against real image signatures.", + "status": "complete", + "one_liner": "Hardened sensitive route auth, sanitized client-error logging, and tightened avatar validation against real image signatures.", + "narrative": "I hardened the main sensitive endpoint paths identified in T01. `AttachmentsController`, `BackupController`, and `ExportController` now require explicit local authentication. `ClientErrorsController` now applies a bounded request size, normalizes/truncates free-form fields, summarizes stack traces into short previews, and logs hashes instead of raw browser stack payloads. `AuthController.UploadAvatar` now uses a tighter 1 MB request limit, requires a supported image extension, reads the uploaded bytes, and detects PNG/JPEG/WebP signatures before persisting a data URL so the server no longer trusts the browser's MIME label alone. I added focused tests for the new auth boundaries, sanitized client-error logging, and avatar signature rejection behavior.", + "verification_result": "Verified with focused controller tests and an API build covering attachments, backup/export auth boundaries, client-error sanitization, and avatar validation.", + "duration": "", + "completed_at": "2026-04-10T22:59:48.937Z", + "blocker_discovered": false, + "deviations": "I kept avatar persistence on the existing `AvatarImageDataUrl` field for this slice instead of redesigning storage, and hardened it by reducing size, validating file extensions, and sniffing real image signatures before persistence.", + "known_issues": "Avatar images are still stored inline on the user record as data URLs because changing the persistence model would broaden the slice. S05 hardens the existing path rather than redesigning it.", + "key_files": [ + "JobTrackerApi/Controllers/AttachmentsController.cs", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Controllers/BackupController.cs", + "JobTrackerApi/Controllers/ExportController.cs", + "JobTrackerApi/Controllers/ClientErrorsController.cs", + "JobTrackerApi.Tests/AttachmentsControllerTests.cs", + "JobTrackerApi.Tests/BackupControllerTests.cs", + "JobTrackerApi.Tests/ClientErrorsControllerTests.cs" + ], + "key_decisions": [ + "Require explicit local auth on backup, export, and attachments routes instead of relying on implicit ownership/query-filter behavior.", + "Sanitize client-error reports by logging bounded normalized fields, preview summaries, and hashes instead of raw browser stack payloads.", + "Validate avatars from detected bytes rather than trusting only client-provided content types." + ], + "full_summary_md": "---\nid: T02\nparent: S05\nmilestone: M011\nkey_files:\n - JobTrackerApi/Controllers/AttachmentsController.cs\n - JobTrackerApi/Controllers/AuthController.cs\n - JobTrackerApi/Controllers/BackupController.cs\n - JobTrackerApi/Controllers/ExportController.cs\n - JobTrackerApi/Controllers/ClientErrorsController.cs\n - JobTrackerApi.Tests/AttachmentsControllerTests.cs\n - JobTrackerApi.Tests/BackupControllerTests.cs\n - JobTrackerApi.Tests/ClientErrorsControllerTests.cs\nkey_decisions:\n - Require explicit local auth on backup, export, and attachments routes instead of relying on implicit ownership/query-filter behavior.\n - Sanitize client-error reports by logging bounded normalized fields, preview summaries, and hashes instead of raw browser stack payloads.\n - Validate avatars from detected bytes rather than trusting only client-provided content types.\nduration: \nverification_result: passed\ncompleted_at: 2026-04-10T22:59:48.936Z\nblocker_discovered: false\n---\n\n# T02: Hardened sensitive route auth, sanitized client-error logging, and tightened avatar validation against real image signatures.\n\n**Hardened sensitive route auth, sanitized client-error logging, and tightened avatar validation against real image signatures.**\n\n## What Happened\n\nI hardened the main sensitive endpoint paths identified in T01. `AttachmentsController`, `BackupController`, and `ExportController` now require explicit local authentication. `ClientErrorsController` now applies a bounded request size, normalizes/truncates free-form fields, summarizes stack traces into short previews, and logs hashes instead of raw browser stack payloads. `AuthController.UploadAvatar` now uses a tighter 1 MB request limit, requires a supported image extension, reads the uploaded bytes, and detects PNG/JPEG/WebP signatures before persisting a data URL so the server no longer trusts the browser's MIME label alone. I added focused tests for the new auth boundaries, sanitized client-error logging, and avatar signature rejection behavior.\n\n## Verification\n\nVerified with focused controller tests and an API build covering attachments, backup/export auth boundaries, client-error sanitization, and avatar validation.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AttachmentsControllerTests|FullyQualifiedName~BackupControllerTests|FullyQualifiedName~ClientErrorsControllerTests|FullyQualifiedName~AuthAndSystemControllerTests\"` | 0 | ✅ pass | 825ms |\n| 2 | `dotnet build JobTrackerApi/JobTrackerApi.csproj` | 0 | ✅ pass | 7920ms |\n\n## Deviations\n\nI kept avatar persistence on the existing `AvatarImageDataUrl` field for this slice instead of redesigning storage, and hardened it by reducing size, validating file extensions, and sniffing real image signatures before persistence.\n\n## Known Issues\n\nAvatar images are still stored inline on the user record as data URLs because changing the persistence model would broaden the slice. S05 hardens the existing path rather than redesigning it.\n\n## Files Created/Modified\n\n- `JobTrackerApi/Controllers/AttachmentsController.cs`\n- `JobTrackerApi/Controllers/AuthController.cs`\n- `JobTrackerApi/Controllers/BackupController.cs`\n- `JobTrackerApi/Controllers/ExportController.cs`\n- `JobTrackerApi/Controllers/ClientErrorsController.cs`\n- `JobTrackerApi.Tests/AttachmentsControllerTests.cs`\n- `JobTrackerApi.Tests/BackupControllerTests.cs`\n- `JobTrackerApi.Tests/ClientErrorsControllerTests.cs`\n", + "description": "1. Require appropriate authorization on backup/export/attachment routes that currently rely only on implicit global filters or open routing.\n2. Tighten avatar and attachment validation/persistence so hostile content types or oversized payloads are rejected cleanly and storage behavior stays bounded.\n3. Replace raw client-error payload logging with a sanitized, size-bounded diagnostic contract that still preserves useful context.", + "estimate": "1.5-2 days", + "files": [ + "JobTrackerApi/Controllers/AttachmentsController.cs", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Controllers/BackupController.cs", + "JobTrackerApi/Controllers/ExportController.cs", + "JobTrackerApi/Controllers/ClientErrorsController.cs", + "JobTrackerApi.Tests/AttachmentsControllerTests.cs", + "JobTrackerApi.Tests/BackupControllerTests.cs", + "JobTrackerApi.Tests/AuthAndSystemControllerTests.cs" + ], + "verify": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AttachmentsControllerTests|FullyQualifiedName~BackupControllerTests|FullyQualifiedName~AuthAndSystemControllerTests\"\ndotnet build JobTrackerApi/JobTrackerApi.csproj", + "inputs": [ + ".gsd/milestones/M011/slices/S05/tasks/T01-SUMMARY.md", + "JobTrackerApi/Controllers/AttachmentsController.cs", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Controllers/BackupController.cs", + "JobTrackerApi/Controllers/ExportController.cs", + "JobTrackerApi/Controllers/ClientErrorsController.cs" + ], + "expected_output": [ + "JobTrackerApi/Controllers/AttachmentsController.cs", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Controllers/BackupController.cs", + "JobTrackerApi/Controllers/ExportController.cs", + "JobTrackerApi/Controllers/ClientErrorsController.cs", + "JobTrackerApi.Tests/AttachmentsControllerTests.cs", + "JobTrackerApi.Tests/BackupControllerTests.cs", + "JobTrackerApi.Tests/AuthAndSystemControllerTests.cs" + ], + "observability_impact": "Rejected or sanitized sensitive payloads should leave bounded, structured logs instead of raw browser stacks or permissive open-route behavior.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S05", + "id": "T03", + "title": "Verified that the newly hardened sensitive routes deny anonymous runtime access and recorded the remaining `client-errors` auth constraint.", + "status": "complete", + "one_liner": "Verified that the newly hardened sensitive routes deny anonymous runtime access and recorded the remaining `client-errors` auth constraint.", + "narrative": "I verified the hardened routes against a live Development API. The app starts successfully, and the newly protected sensitive routes now reject anonymous access at runtime: `GET /api/export/jobs`, `POST /api/backup/encrypted`, and `GET /api/attachments/1` all return 401 without a session. The focused controller tests also remain green, covering the same auth-boundary changes plus client-error sanitization and avatar-signature validation. During the runtime pass I also checked `POST /api/client-errors`; under the current auth-required environment it returns 401 anonymously. I’m recording that as a current behavior constraint rather than broadening the slice into an auth-policy redesign for pre-auth error reporting.", + "verification_result": "Verified with focused controller tests, a successful API build, a Development startup run on `http://localhost:5202`, and anonymous HTTP checks against export, backup, attachments, and client-error routes.", + "duration": "", + "completed_at": "2026-04-10T23:00:30.313Z", + "blocker_discovered": false, + "deviations": "The runtime pass showed `POST /api/client-errors` returns 401 for anonymous requests under the current environment-wide auth requirement. I’m recording that as an observed behavior constraint rather than broadening S05 into a policy change about whether unauthenticated browser errors should be accepted.", + "known_issues": "In the current auth-required Development environment, anonymous `POST /api/client-errors` returns 401. That is safer than open anonymous logging, but it means login-page or pre-auth browser errors are not submitted unless the auth policy changes deliberately later.", + "key_files": [ + "JobTrackerApi/Controllers/AttachmentsController.cs", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Controllers/BackupController.cs", + "JobTrackerApi/Controllers/ExportController.cs", + "JobTrackerApi/Controllers/ClientErrorsController.cs", + "JobTrackerApi.Tests/AttachmentsControllerTests.cs", + "JobTrackerApi.Tests/BackupControllerTests.cs", + "JobTrackerApi.Tests/ClientErrorsControllerTests.cs" + ], + "key_decisions": [ + "Use runtime 401 checks against hardened routes as the final proof instead of relying only on reflection tests.", + "Record the current auth behavior on `client-errors` explicitly rather than silently treating it as either a bug or a feature." + ], + "full_summary_md": "---\nid: T03\nparent: S05\nmilestone: M011\nkey_files:\n - JobTrackerApi/Controllers/AttachmentsController.cs\n - JobTrackerApi/Controllers/AuthController.cs\n - JobTrackerApi/Controllers/BackupController.cs\n - JobTrackerApi/Controllers/ExportController.cs\n - JobTrackerApi/Controllers/ClientErrorsController.cs\n - JobTrackerApi.Tests/AttachmentsControllerTests.cs\n - JobTrackerApi.Tests/BackupControllerTests.cs\n - JobTrackerApi.Tests/ClientErrorsControllerTests.cs\nkey_decisions:\n - Use runtime 401 checks against hardened routes as the final proof instead of relying only on reflection tests.\n - Record the current auth behavior on `client-errors` explicitly rather than silently treating it as either a bug or a feature.\nduration: \nverification_result: passed\ncompleted_at: 2026-04-10T23:00:30.312Z\nblocker_discovered: false\n---\n\n# T03: Verified that the newly hardened sensitive routes deny anonymous runtime access and recorded the remaining `client-errors` auth constraint.\n\n**Verified that the newly hardened sensitive routes deny anonymous runtime access and recorded the remaining `client-errors` auth constraint.**\n\n## What Happened\n\nI verified the hardened routes against a live Development API. The app starts successfully, and the newly protected sensitive routes now reject anonymous access at runtime: `GET /api/export/jobs`, `POST /api/backup/encrypted`, and `GET /api/attachments/1` all return 401 without a session. The focused controller tests also remain green, covering the same auth-boundary changes plus client-error sanitization and avatar-signature validation. During the runtime pass I also checked `POST /api/client-errors`; under the current auth-required environment it returns 401 anonymously. I’m recording that as a current behavior constraint rather than broadening the slice into an auth-policy redesign for pre-auth error reporting.\n\n## Verification\n\nVerified with focused controller tests, a successful API build, a Development startup run on `http://localhost:5202`, and anonymous HTTP checks against export, backup, attachments, and client-error routes.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AttachmentsControllerTests|FullyQualifiedName~BackupControllerTests|FullyQualifiedName~ClientErrorsControllerTests|FullyQualifiedName~AuthAndSystemControllerTests\"` | 0 | ✅ pass | 825ms |\n| 2 | `dotnet build JobTrackerApi/JobTrackerApi.csproj` | 0 | ✅ pass | 7920ms |\n| 3 | `GET http://localhost:5202/api/export/jobs` | 401 | ✅ pass | 0ms |\n| 4 | `POST http://localhost:5202/api/backup/encrypted` | 401 | ✅ pass | 0ms |\n| 5 | `GET http://localhost:5202/api/attachments/1` | 401 | ✅ pass | 0ms |\n| 6 | `POST http://localhost:5202/api/client-errors` | 401 | ✅ observed-constraint | 0ms |\n\n## Deviations\n\nThe runtime pass showed `POST /api/client-errors` returns 401 for anonymous requests under the current environment-wide auth requirement. I’m recording that as an observed behavior constraint rather than broadening S05 into a policy change about whether unauthenticated browser errors should be accepted.\n\n## Known Issues\n\nIn the current auth-required Development environment, anonymous `POST /api/client-errors` returns 401. That is safer than open anonymous logging, but it means login-page or pre-auth browser errors are not submitted unless the auth policy changes deliberately later.\n\n## Files Created/Modified\n\n- `JobTrackerApi/Controllers/AttachmentsController.cs`\n- `JobTrackerApi/Controllers/AuthController.cs`\n- `JobTrackerApi/Controllers/BackupController.cs`\n- `JobTrackerApi/Controllers/ExportController.cs`\n- `JobTrackerApi/Controllers/ClientErrorsController.cs`\n- `JobTrackerApi.Tests/AttachmentsControllerTests.cs`\n- `JobTrackerApi.Tests/BackupControllerTests.cs`\n- `JobTrackerApi.Tests/ClientErrorsControllerTests.cs`\n", + "description": "1. Run focused controller tests covering authorized access, denied access, and malformed input for the hardened routes.\n2. Rebuild the API and, where practical, exercise the key auth-sensitive endpoints through HTTP to confirm the contract still behaves correctly.\n3. Record any remaining operational constraints without broadening S05 into unrelated admin UX work.", + "estimate": "0.5-1 day", + "files": [ + "JobTrackerApi/Controllers/AttachmentsController.cs", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Controllers/BackupController.cs", + "JobTrackerApi/Controllers/ExportController.cs", + "JobTrackerApi/Controllers/ClientErrorsController.cs", + "JobTrackerApi.Tests/AttachmentsControllerTests.cs", + "JobTrackerApi.Tests/BackupControllerTests.cs", + "JobTrackerApi.Tests/AuthAndSystemControllerTests.cs" + ], + "verify": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AttachmentsControllerTests|FullyQualifiedName~BackupControllerTests|FullyQualifiedName~AuthAndSystemControllerTests\"\ndotnet build JobTrackerApi/JobTrackerApi.csproj", + "inputs": [ + "JobTrackerApi/Controllers/AttachmentsController.cs", + "JobTrackerApi/Controllers/AuthController.cs", + "JobTrackerApi/Controllers/BackupController.cs", + "JobTrackerApi/Controllers/ExportController.cs", + "JobTrackerApi/Controllers/ClientErrorsController.cs", + "JobTrackerApi.Tests/AttachmentsControllerTests.cs", + "JobTrackerApi.Tests/BackupControllerTests.cs", + "JobTrackerApi.Tests/AuthAndSystemControllerTests.cs" + ], + "expected_output": [ + ".gsd/milestones/M011/slices/S05/tasks/T03-SUMMARY.md" + ], + "observability_impact": "Verification should prove both successful authorized behavior and explicit denial/sanitization behavior for sensitive routes.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S06", + "id": "T01", + "title": "Mapped the real S06 seam around import-time AI startup, optional Ollama degradation, and API-side failure/metrics behavior.", + "status": "complete", + "one_liner": "Mapped the real S06 seam around import-time AI startup, optional Ollama degradation, and API-side failure/metrics behavior.", + "narrative": "I mapped the AI/Ollama reliability seam across the Python service, the .NET wrapper, and the deployment contract. The most important startup risk is in `tools/summarizer/app.py`: the Hugging Face model loads at import time unless `AI_SERVICE_SKIP_MODEL_LOAD=1`, which means service startup and health semantics are tightly coupled to heavy model initialization. Optional Ollama-backed CV classification uses synchronous request flow and explicit HTTP exceptions, but the surrounding capability story is still split between ad hoc endpoint behavior and `/health`. On the API side, `SummarizerService` tracks useful metrics and last-error state, but caller-facing failures for summarize and OCR often collapse to `null`, so degraded states are mostly diagnosable through admin metrics rather than through a richer service contract. Existing tests are minimal and structural: Python tests cover `health` without Ollama and basic CV classification shaping, while .NET tests only assert that the metrics contract exposes runtime device fields and that the summarizer cache key seam exists. That gives S06 a clean next seam: harden explicit capability reporting and degraded-path behavior in the Python service and .NET wrapper, then add focused tests for those scenarios.", + "verification_result": "Verified by inspecting the Python AI service, the .NET summarizer wrapper, deployment/runtime config, and existing focused tests for AI contract coverage.", + "duration": "", + "completed_at": "2026-04-10T23:20:01.927Z", + "blocker_discovered": false, + "deviations": "I included `docker-compose.yml` and the summarizer test module in the seam mapping because the runtime contract matters here as much as the code itself. S06’s reliability issues cross the API, the Python service, and the container startup assumptions.", + "known_issues": "At the start of S06, the Python AI service loads the summarization model at import time unless `AI_SERVICE_SKIP_MODEL_LOAD=1`, Ollama calls are synchronous and optional but failure modes are coarse, and the .NET wrapper often collapses request failures to `null` with metrics carrying most of the detail after the fact.", + "key_files": [ + "tools/summarizer/app.py", + "tools/summarizer/tests/test_app.py", + "JobTrackerApi/Services/SummarizerService.cs", + "JobTrackerApi.Tests/AuthAndSystemControllerTests.cs", + "JobTrackerApi.Tests/ProductionConfigTests.cs", + "JobTrackerApi.Tests/OwnershipGuardTests.cs", + "docker-compose.yml" + ], + "key_decisions": [ + "Treat import-time model load in `tools/summarizer/app.py` as the primary startup reliability seam rather than broadening immediately into model-quality work.", + "Treat AI reliability as two linked contracts: Python service capability reporting and API-side failure/metrics behavior.", + "Keep S06 focused on explicit degraded-path behavior and observability rather than redesigning all AI call sites." + ], + "full_summary_md": "---\nid: T01\nparent: S06\nmilestone: M011\nkey_files:\n - tools/summarizer/app.py\n - tools/summarizer/tests/test_app.py\n - JobTrackerApi/Services/SummarizerService.cs\n - JobTrackerApi.Tests/AuthAndSystemControllerTests.cs\n - JobTrackerApi.Tests/ProductionConfigTests.cs\n - JobTrackerApi.Tests/OwnershipGuardTests.cs\n - docker-compose.yml\nkey_decisions:\n - Treat import-time model load in `tools/summarizer/app.py` as the primary startup reliability seam rather than broadening immediately into model-quality work.\n - Treat AI reliability as two linked contracts: Python service capability reporting and API-side failure/metrics behavior.\n - Keep S06 focused on explicit degraded-path behavior and observability rather than redesigning all AI call sites.\nduration: \nverification_result: mixed\ncompleted_at: 2026-04-10T23:20:01.926Z\nblocker_discovered: false\n---\n\n# T01: Mapped the real S06 seam around import-time AI startup, optional Ollama degradation, and API-side failure/metrics behavior.\n\n**Mapped the real S06 seam around import-time AI startup, optional Ollama degradation, and API-side failure/metrics behavior.**\n\n## What Happened\n\nI mapped the AI/Ollama reliability seam across the Python service, the .NET wrapper, and the deployment contract. The most important startup risk is in `tools/summarizer/app.py`: the Hugging Face model loads at import time unless `AI_SERVICE_SKIP_MODEL_LOAD=1`, which means service startup and health semantics are tightly coupled to heavy model initialization. Optional Ollama-backed CV classification uses synchronous request flow and explicit HTTP exceptions, but the surrounding capability story is still split between ad hoc endpoint behavior and `/health`. On the API side, `SummarizerService` tracks useful metrics and last-error state, but caller-facing failures for summarize and OCR often collapse to `null`, so degraded states are mostly diagnosable through admin metrics rather than through a richer service contract. Existing tests are minimal and structural: Python tests cover `health` without Ollama and basic CV classification shaping, while .NET tests only assert that the metrics contract exposes runtime device fields and that the summarizer cache key seam exists. That gives S06 a clean next seam: harden explicit capability reporting and degraded-path behavior in the Python service and .NET wrapper, then add focused tests for those scenarios.\n\n## Verification\n\nVerified by inspecting the Python AI service, the .NET summarizer wrapper, deployment/runtime config, and existing focused tests for AI contract coverage.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `rg -n \"OLLAMA|SKIP_MODEL_LOAD|/health|/summarize|/extract-text|RunProbeAsync|GetMetricsAsync|LastError|Probe\" tools/summarizer JobTrackerApi/Services JobTrackerApi.Tests -S` | -1 | unknown (coerced from string) | 0ms |\n\n## Deviations\n\nI included `docker-compose.yml` and the summarizer test module in the seam mapping because the runtime contract matters here as much as the code itself. S06’s reliability issues cross the API, the Python service, and the container startup assumptions.\n\n## Known Issues\n\nAt the start of S06, the Python AI service loads the summarization model at import time unless `AI_SERVICE_SKIP_MODEL_LOAD=1`, Ollama calls are synchronous and optional but failure modes are coarse, and the .NET wrapper often collapses request failures to `null` with metrics carrying most of the detail after the fact.\n\n## Files Created/Modified\n\n- `tools/summarizer/app.py`\n- `tools/summarizer/tests/test_app.py`\n- `JobTrackerApi/Services/SummarizerService.cs`\n- `JobTrackerApi.Tests/AuthAndSystemControllerTests.cs`\n- `JobTrackerApi.Tests/ProductionConfigTests.cs`\n- `JobTrackerApi.Tests/OwnershipGuardTests.cs`\n- `docker-compose.yml`\n", + "description": "1. Inspect `tools/summarizer/app.py`, `JobTrackerApi/Services/SummarizerService.cs`, and current tests to map the real reliability seams: import-time model load, synchronous Ollama calls, OCR extraction limits, probe assumptions, and silent API-side failure handling.\n2. Separate the work into service-contract hardening, API metrics/failure-surface hardening, and verification.\n3. Record the narrowest S06 implementation seam that materially improves operability without redesigning the whole AI stack.", + "estimate": "0.5 day", + "files": [ + "tools/summarizer/app.py", + "tools/summarizer/tests/test_app.py", + "JobTrackerApi/Services/SummarizerService.cs", + "JobTrackerApi/Controllers/AdminSystemController.cs", + "tools/summarizer/README.md", + "docker-compose.yml" + ], + "verify": "rg -n \"OLLAMA|SKIP_MODEL_LOAD|/health|/summarize|/extract-text|RunProbeAsync|GetMetricsAsync|LastError|Probe\" tools/summarizer JobTrackerApi/Services JobTrackerApi.Tests -S", + "inputs": [ + "tools/summarizer/app.py", + "tools/summarizer/tests/test_app.py", + "JobTrackerApi/Services/SummarizerService.cs", + "JobTrackerApi/Controllers/AdminSystemController.cs", + "tools/summarizer/README.md", + "docker-compose.yml" + ], + "expected_output": [ + ".gsd/milestones/M011/slices/S06/tasks/T01-SUMMARY.md" + ], + "observability_impact": "Maps which AI failures are currently collapsed together and which capability states are already surfaced versus still implicit.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S06", + "id": "T02", + "title": "Hardened AI/Ollama capability reporting with lazy model loading, explicit degraded health state, and clearer API-side error interpretation.", + "status": "complete", + "one_liner": "Hardened AI/Ollama capability reporting with lazy model loading, explicit degraded health state, and clearer API-side error interpretation.", + "narrative": "I hardened the AI reliability contract across both sides of the boundary. In `tools/summarizer/app.py`, the summarization model now loads lazily instead of at module import by default. The service tracks explicit runtime state through `MODEL_LOADED`, `MODEL_DISABLED`, and `MODEL_LOAD_ERROR`, and `/health` now reports `model_loaded`, `model_disabled`, `summarize_available`, and `model_load_error` without forcing a hidden warm-up. The `/summarize` path still loads the model on demand, but when model loading is disabled or fails it now returns a clear 503 reason instead of a generic missing-model message. I expanded `tools/summarizer/tests/test_app.py` to cover the disabled-model health path, explicit 503 summarize behavior, and configured-but-unreachable Ollama behavior. On the .NET side, `SummarizerService` now preserves more useful detail from failed summarize/OCR/probe responses, and `GetMetricsAsync` now treats `summarize_available=false` from `/health` as unhealthy so the admin/system metrics surface does not misreport the AI service as healthy just because the HTTP endpoint responded 200.", + "verification_result": "Verified with the summarizer Python test suite via the project bootstrap script, focused .NET tests, and repeated API builds after the AI-service contract changes.", + "duration": "", + "completed_at": "2026-04-10T23:24:03.785Z", + "blocker_discovered": false, + "deviations": "I kept the existing `AiServiceMetrics` shape instead of widening it with new model-state fields. Instead, I propagated the new Python health semantics through the existing `Healthy` and `LastError` interpretation so the admin/API surface becomes clearer without broad contract churn.", + "known_issues": "`AiServiceMetrics` still does not expose dedicated fields for model-loaded versus model-disabled state; that detail is currently conveyed through the Python `/health` payload and the .NET `Healthy`/`LastError` interpretation rather than a widened .NET DTO.", + "key_files": [ + "tools/summarizer/app.py", + "tools/summarizer/tests/test_app.py", + "JobTrackerApi/Services/SummarizerService.cs", + "tools/summarizer/README.md" + ], + "key_decisions": [ + "Make the Python summarizer model load lazy by default instead of import-time eager, and expose that state explicitly through `/health`.", + "Treat `summarize_available=false` from the Python service as unhealthy in the .NET metrics layer so the admin surface does not report a false healthy state when summarization is disabled or failed to load.", + "Preserve the existing caller-facing `null` return contract in `SummarizerService` for now, but improve the recorded error detail from failed AI/probe/OCR responses." + ], + "full_summary_md": "---\nid: T02\nparent: S06\nmilestone: M011\nkey_files:\n - tools/summarizer/app.py\n - tools/summarizer/tests/test_app.py\n - JobTrackerApi/Services/SummarizerService.cs\n - tools/summarizer/README.md\nkey_decisions:\n - Make the Python summarizer model load lazy by default instead of import-time eager, and expose that state explicitly through `/health`.\n - Treat `summarize_available=false` from the Python service as unhealthy in the .NET metrics layer so the admin surface does not report a false healthy state when summarization is disabled or failed to load.\n - Preserve the existing caller-facing `null` return contract in `SummarizerService` for now, but improve the recorded error detail from failed AI/probe/OCR responses.\nduration: \nverification_result: passed\ncompleted_at: 2026-04-10T23:24:03.783Z\nblocker_discovered: false\n---\n\n# T02: Hardened AI/Ollama capability reporting with lazy model loading, explicit degraded health state, and clearer API-side error interpretation.\n\n**Hardened AI/Ollama capability reporting with lazy model loading, explicit degraded health state, and clearer API-side error interpretation.**\n\n## What Happened\n\nI hardened the AI reliability contract across both sides of the boundary. In `tools/summarizer/app.py`, the summarization model now loads lazily instead of at module import by default. The service tracks explicit runtime state through `MODEL_LOADED`, `MODEL_DISABLED`, and `MODEL_LOAD_ERROR`, and `/health` now reports `model_loaded`, `model_disabled`, `summarize_available`, and `model_load_error` without forcing a hidden warm-up. The `/summarize` path still loads the model on demand, but when model loading is disabled or fails it now returns a clear 503 reason instead of a generic missing-model message. I expanded `tools/summarizer/tests/test_app.py` to cover the disabled-model health path, explicit 503 summarize behavior, and configured-but-unreachable Ollama behavior. On the .NET side, `SummarizerService` now preserves more useful detail from failed summarize/OCR/probe responses, and `GetMetricsAsync` now treats `summarize_available=false` from `/health` as unhealthy so the admin/system metrics surface does not misreport the AI service as healthy just because the HTTP endpoint responded 200.\n\n## Verification\n\nVerified with the summarizer Python test suite via the project bootstrap script, focused .NET tests, and repeated API builds after the AI-service contract changes.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `cd tools/summarizer && ./scripts/bootstrap-and-test.sh test` | 0 | ✅ pass | 3090ms |\n| 2 | `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AuthAndSystemControllerTests|FullyQualifiedName~ProductionConfigTests|FullyQualifiedName~OwnershipGuardTests\"` | 0 | ✅ pass | 171ms |\n| 3 | `dotnet build JobTrackerApi/JobTrackerApi.csproj` | 0 | ✅ pass | 3680ms |\n\n## Deviations\n\nI kept the existing `AiServiceMetrics` shape instead of widening it with new model-state fields. Instead, I propagated the new Python health semantics through the existing `Healthy` and `LastError` interpretation so the admin/API surface becomes clearer without broad contract churn.\n\n## Known Issues\n\n`AiServiceMetrics` still does not expose dedicated fields for model-loaded versus model-disabled state; that detail is currently conveyed through the Python `/health` payload and the .NET `Healthy`/`LastError` interpretation rather than a widened .NET DTO.\n\n## Files Created/Modified\n\n- `tools/summarizer/app.py`\n- `tools/summarizer/tests/test_app.py`\n- `JobTrackerApi/Services/SummarizerService.cs`\n- `tools/summarizer/README.md`\n", + "description": "1. Harden the Python AI service so model-load, health, summarize, OCR, and optional Ollama paths expose explicit capability/failure state without blocking unrelated features unnecessarily.\n2. Tighten the API summarizer service so failed AI calls preserve more useful diagnostics/metrics and degrade predictably for callers.\n3. Add or update focused tests for degraded Ollama/model scenarios and the API-side metrics/failure contract.", + "estimate": "1.5-2.5 days", + "files": [ + "tools/summarizer/app.py", + "tools/summarizer/tests/test_app.py", + "JobTrackerApi/Services/SummarizerService.cs", + "JobTrackerApi.Tests", + "tools/summarizer/README.md" + ], + "verify": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AuthAndSystemControllerTests|FullyQualifiedName~ProductionConfigTests|FullyQualifiedName~OwnershipGuardTests\"\ncd tools/summarizer && ./scripts/bootstrap-and-test.sh test", + "inputs": [ + ".gsd/milestones/M011/slices/S06/tasks/T01-SUMMARY.md", + "tools/summarizer/app.py", + "JobTrackerApi/Services/SummarizerService.cs" + ], + "expected_output": [ + "tools/summarizer/app.py", + "tools/summarizer/tests/test_app.py", + "JobTrackerApi/Services/SummarizerService.cs", + "JobTrackerApi.Tests/", + "tools/summarizer/README.md" + ], + "observability_impact": "Healthy versus degraded AI capability states should become visible through health/metrics surfaces and tests should cover bounded failure behavior.", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M011", + "slice_id": "S06", + "id": "T03", + "title": "Verified the AI/Ollama reliability contract through focused Python and .NET tests and recorded the remaining environment-only limits.", + "status": "complete", + "one_liner": "Verified the AI/Ollama reliability contract through focused Python and .NET tests and recorded the remaining environment-only limits.", + "narrative": "I verified the hardened AI/Ollama contract at the right boundary for this slice. The Python summarizer tests now prove that `/health` reports explicit disabled-model state without forcing warm-up, that `/summarize` returns a clear 503 when model loading is disabled, and that configured-but-unreachable Ollama is surfaced cleanly through health metadata. The focused .NET tests still pass after the `SummarizerService` changes, and the API builds cleanly. Together that gives the slice the evidence it needed: the AI service can describe its own degraded capability state, the API no longer treats `summarize_available=false` as healthy, and the remaining limits are environment/model-deployment concerns rather than hidden contract ambiguity.", + "verification_result": "Verified with `cd tools/summarizer && ./scripts/bootstrap-and-test.sh test`, `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AuthAndSystemControllerTests|FullyQualifiedName~ProductionConfigTests|FullyQualifiedName~OwnershipGuardTests\"`, and `dotnet build JobTrackerApi/JobTrackerApi.csproj`.", + "duration": "", + "completed_at": "2026-04-10T23:24:23.066Z", + "blocker_discovered": false, + "deviations": "Verification stayed at the focused test and health-contract level instead of running a full live Ollama stack. That was the right boundary for S06 because the slice target was explicit degraded behavior and capability reporting, not proving a specific local model deployment.", + "known_issues": "Live verification against a real local Ollama instance was not required for this slice. The contract is now explicit for configured-but-unreachable and model-disabled states, but actual model pull/load latency remains environment-dependent.", + "key_files": [ + "tools/summarizer/app.py", + "tools/summarizer/tests/test_app.py", + "JobTrackerApi/Services/SummarizerService.cs", + "tools/summarizer/README.md" + ], + "key_decisions": [ + "Use the project bootstrap script for Python verification instead of assuming host-level `pytest` availability.", + "Treat environment-missing Ollama as a degraded-path contract to verify, not a blocker for completing the slice." + ], + "full_summary_md": "---\nid: T03\nparent: S06\nmilestone: M011\nkey_files:\n - tools/summarizer/app.py\n - tools/summarizer/tests/test_app.py\n - JobTrackerApi/Services/SummarizerService.cs\n - tools/summarizer/README.md\nkey_decisions:\n - Use the project bootstrap script for Python verification instead of assuming host-level `pytest` availability.\n - Treat environment-missing Ollama as a degraded-path contract to verify, not a blocker for completing the slice.\nduration: \nverification_result: passed\ncompleted_at: 2026-04-10T23:24:23.065Z\nblocker_discovered: false\n---\n\n# T03: Verified the AI/Ollama reliability contract through focused Python and .NET tests and recorded the remaining environment-only limits.\n\n**Verified the AI/Ollama reliability contract through focused Python and .NET tests and recorded the remaining environment-only limits.**\n\n## What Happened\n\nI verified the hardened AI/Ollama contract at the right boundary for this slice. The Python summarizer tests now prove that `/health` reports explicit disabled-model state without forcing warm-up, that `/summarize` returns a clear 503 when model loading is disabled, and that configured-but-unreachable Ollama is surfaced cleanly through health metadata. The focused .NET tests still pass after the `SummarizerService` changes, and the API builds cleanly. Together that gives the slice the evidence it needed: the AI service can describe its own degraded capability state, the API no longer treats `summarize_available=false` as healthy, and the remaining limits are environment/model-deployment concerns rather than hidden contract ambiguity.\n\n## Verification\n\nVerified with `cd tools/summarizer && ./scripts/bootstrap-and-test.sh test`, `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AuthAndSystemControllerTests|FullyQualifiedName~ProductionConfigTests|FullyQualifiedName~OwnershipGuardTests\"`, and `dotnet build JobTrackerApi/JobTrackerApi.csproj`.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `cd tools/summarizer && ./scripts/bootstrap-and-test.sh test` | 0 | ✅ pass | 3090ms |\n| 2 | `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AuthAndSystemControllerTests|FullyQualifiedName~ProductionConfigTests|FullyQualifiedName~OwnershipGuardTests\"` | 0 | ✅ pass | 171ms |\n| 3 | `dotnet build JobTrackerApi/JobTrackerApi.csproj` | 0 | ✅ pass | 3680ms |\n\n## Deviations\n\nVerification stayed at the focused test and health-contract level instead of running a full live Ollama stack. That was the right boundary for S06 because the slice target was explicit degraded behavior and capability reporting, not proving a specific local model deployment.\n\n## Known Issues\n\nLive verification against a real local Ollama instance was not required for this slice. The contract is now explicit for configured-but-unreachable and model-disabled states, but actual model pull/load latency remains environment-dependent.\n\n## Files Created/Modified\n\n- `tools/summarizer/app.py`\n- `tools/summarizer/tests/test_app.py`\n- `JobTrackerApi/Services/SummarizerService.cs`\n- `tools/summarizer/README.md`\n", + "description": "1. Verify the hardened AI/Ollama contract with focused .NET and Python tests plus health/behavior checks where practical.\n2. Confirm the API/admin metrics surface still reports useful capability state without requiring a fully healthy Ollama instance.\n3. Record any remaining environment-only limitations separately from product behavior.", + "estimate": "0.5-1 day", + "files": [ + "tools/summarizer/app.py", + "tools/summarizer/tests/test_app.py", + "JobTrackerApi/Services/SummarizerService.cs", + "JobTrackerApi/Controllers/AdminSystemController.cs", + "JobTrackerApi.Tests" + ], + "verify": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AuthAndSystemControllerTests|FullyQualifiedName~ProductionConfigTests|FullyQualifiedName~OwnershipGuardTests\"\ncd tools/summarizer && ./scripts/bootstrap-and-test.sh test", + "inputs": [ + "tools/summarizer/app.py", + "tools/summarizer/tests/test_app.py", + "JobTrackerApi/Services/SummarizerService.cs", + "JobTrackerApi/Controllers/AdminSystemController.cs", + "JobTrackerApi.Tests" + ], + "expected_output": [ + ".gsd/milestones/M011/slices/S06/tasks/T03-SUMMARY.md" + ], + "observability_impact": "Verification should prove that AI capability state stays explicit under both available and degraded dependency conditions.", + "full_plan_md": "", + "sequence": 0 } ], "decisions": [ @@ -1684,7 +3466,783 @@ "revisable": "Yes", "made_by": "agent", "superseded_by": null + }, + { + "seq": 17, + "id": "D017", + "when_context": "M005 planning", + "scope": "delivery", + "decision": "How M005 execution should be staged and published", + "choice": "Execute M005 one slice at a time, verify each slice independently, push each slice on its own git branch, then continue to the next slice only after the prior slice is stable.", + "rationale": "The CV intelligence/export milestone is high-risk and multi-layered. Slice-by-slice branching and push discipline will keep extraction, tailored draft, and PDF rendering changes reviewable and reduce regression blast radius.", + "revisable": "Yes", + "made_by": "human", + "superseded_by": null + }, + { + "seq": 18, + "id": "D018", + "when_context": "M005 planning", + "scope": "verification", + "decision": "What document corpus should drive universal CV extraction verification", + "choice": "Use the real CV files placed in /home/pi/cvs as a regression corpus for universal extractor work, alongside synthetic/unit fixtures.", + "rationale": "A universal CV extractor cannot be validated only against synthetic fixtures. Real CVs with different layouts, OCR quality, and structure are required to test extraction, review UX, and rendering assumptions.", + "revisable": "Yes", + "made_by": "human", + "superseded_by": null + }, + { + "seq": 19, + "id": "D019", + "when_context": "M011/S01", + "scope": "frontend-platform", + "decision": "How to handle frontend build-tool risk during the initial platform hardening slice", + "choice": "Remediate the direct critical frontend dependency immediately, keep the CRA baseline for the next hardening slice, and defer the broader frontend build-tool migration to a later dedicated implementation step.", + "rationale": "The audit showed one critical direct dependency issue (`axios`) and a large remaining body of transitive risk concentrated behind `react-scripts`. Upgrading the direct dependency removed the critical finding with low change surface, restored a reproducible local and Docker build baseline, and avoids coupling S02 auth/session work to a framework migration. The remaining CRA transitive debt is still real, but it is now a contained follow-on migration concern rather than an immediate blocker.", + "revisable": "Yes", + "made_by": "agent", + "superseded_by": null + }, + { + "seq": 20, + "id": "D020", + "when_context": "M011/S02", + "scope": "authentication", + "decision": "What session transport should replace browser-stored bearer tokens in the frontend and API", + "choice": "Use an HttpOnly cookie-backed app session for the primary local auth path, have the API read the local app JWT from a secure cookie instead of browser storage, keep Google credential exchange server-side, and add CSRF protection for state-changing requests.", + "rationale": "The current design stores the app bearer token in localStorage/sessionStorage and attaches it via an Authorization header on every request, which leaves the primary local auth path exposed to XSS-driven token theft. A cookie-backed session keeps the app token out of browser storage, lets the API enforce the local auth path centrally, preserves existing JWT-based authorization semantics on the server, and gives the frontend a cleaner source of truth through `/auth/me` and explicit unauthorized responses. Adding CSRF protection alongside the cookie keeps state-changing requests safe under the new transport.", + "revisable": "Yes", + "made_by": "agent", + "superseded_by": null + }, + { + "seq": 21, + "id": "D021", + "when_context": "M011/S03/T01", + "scope": "frontend-architecture", + "decision": "How to centralize degraded-state handling for the core frontend views in S03.", + "choice": "Use a lightweight shared frontend async-view-state pattern for S03 instead of introducing a new global data-fetching framework in this slice.", + "rationale": "The current risk is not lack of a full query library; it is that core views swallow request failures into empty arrays or nulls and then render normal empty states. A small shared abstraction for loading/empty/error/retry state can retire that product risk quickly across the highest-traffic views without broadening S03 into a framework migration or destabilizing the existing app.", + "revisable": "Yes", + "made_by": "agent", + "superseded_by": null } ], - "verification_evidence": [] + "verification_evidence": [ + { + "id": 1, + "task_id": "T01", + "slice_id": "S01", + "milestone_id": "M011", + "command": "Baseline: `cd job-tracker-ui && npm audit --audit-level=moderate --json` showed 28 vulnerabilities including 1 critical direct finding on axios <1.15.0.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:45:12.983Z" + }, + { + "id": 2, + "task_id": "T01", + "slice_id": "S01", + "milestone_id": "M011", + "command": "Baseline: `cd job-tracker-ui && npm run build` initially failed with EACCES because `job-tracker-ui/build/static` contained root-owned files.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:45:12.983Z" + }, + { + "id": 3, + "task_id": "T01", + "slice_id": "S01", + "milestone_id": "M011", + "command": "After remediation: `cd job-tracker-ui && npm install` updated axios to 1.15.0 and removed the direct critical vulnerability.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:45:12.984Z" + }, + { + "id": 4, + "task_id": "T01", + "slice_id": "S01", + "milestone_id": "M011", + "command": "After remediation: `cd job-tracker-ui && npm audit --audit-level=moderate --json` showed 27 remaining vulnerabilities and 0 critical findings.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:45:12.984Z" + }, + { + "id": 5, + "task_id": "T01", + "slice_id": "S01", + "milestone_id": "M011", + "command": "After remediation: `cd job-tracker-ui && npm run build` completed successfully.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:45:12.984Z" + }, + { + "id": 6, + "task_id": "T01", + "slice_id": "S01", + "milestone_id": "M011", + "command": "Compatibility check: `docker run --rm -v ... node:20-alpine sh -lc 'npm ci'` initially failed on a lockfile mismatch (`Missing: yaml@2.8.3 from lock file`).", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:45:12.984Z" + }, + { + "id": 7, + "task_id": "T01", + "slice_id": "S01", + "milestone_id": "M011", + "command": "Compatibility fix: `docker run --rm --user 1000:1000 -v ... node:20-alpine sh -lc 'npm install'` regenerated the lockfile using the same npm major version as the Docker image.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:45:12.984Z" + }, + { + "id": 8, + "task_id": "T01", + "slice_id": "S01", + "milestone_id": "M011", + "command": "After compatibility fix: `docker run --rm -v ... node:20-alpine sh -lc 'npm ci --foreground-scripts=false'` completed successfully.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:45:12.984Z" + }, + { + "id": 9, + "task_id": "T01", + "slice_id": "S01", + "milestone_id": "M011", + "command": "Container verification: `cd /home/pi/development/JobTracker && docker compose build frontend` completed successfully.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:45:12.984Z" + }, + { + "id": 10, + "task_id": "T01", + "slice_id": "S01", + "milestone_id": "M011", + "command": "Regression signal: `cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false` finished with 16 passing suites and 2 failing suites (`daily-control-loop.test.tsx`, `end-to-end-trust-loop.test.tsx`) that appear unrelated to the dependency remediation itself.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:45:12.984Z" + }, + { + "id": 11, + "task_id": "T02", + "slice_id": "S01", + "milestone_id": "M011", + "command": "`cd job-tracker-ui && npm install` updated axios to 1.15.0 and synchronized the package tree.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:46:52.965Z" + }, + { + "id": 12, + "task_id": "T02", + "slice_id": "S01", + "milestone_id": "M011", + "command": "`cd job-tracker-ui && npm run build` succeeded after the workspace cleanup.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:46:52.965Z" + }, + { + "id": 13, + "task_id": "T02", + "slice_id": "S01", + "milestone_id": "M011", + "command": "`docker run --rm --user 1000:1000 -v /home/pi/development/JobTracker/job-tracker-ui:/app -w /app node:20-alpine sh -lc 'npm install'` regenerated the lockfile for the container toolchain.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:46:52.965Z" + }, + { + "id": 14, + "task_id": "T02", + "slice_id": "S01", + "milestone_id": "M011", + "command": "`docker run --rm -v /home/pi/development/JobTracker/job-tracker-ui:/app -w /app node:20-alpine sh -lc 'npm ci --foreground-scripts=false'` succeeded.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:46:52.965Z" + }, + { + "id": 15, + "task_id": "T02", + "slice_id": "S01", + "milestone_id": "M011", + "command": "`cd /home/pi/development/JobTracker && docker compose build frontend` succeeded.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:46:52.965Z" + }, + { + "id": 16, + "task_id": "T03", + "slice_id": "S01", + "milestone_id": "M011", + "command": "`cd job-tracker-ui && npm audit --audit-level=moderate --json` now reports 0 critical findings and 27 remaining transitive findings.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:47:07.042Z" + }, + { + "id": 17, + "task_id": "T03", + "slice_id": "S01", + "milestone_id": "M011", + "command": "`cd job-tracker-ui && npm run build` succeeded.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:47:07.042Z" + }, + { + "id": 18, + "task_id": "T03", + "slice_id": "S01", + "milestone_id": "M011", + "command": "`docker run --rm -v /home/pi/development/JobTracker/job-tracker-ui:/app -w /app node:20-alpine sh -lc 'npm ci --foreground-scripts=false'` succeeded.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:47:07.042Z" + }, + { + "id": 19, + "task_id": "T03", + "slice_id": "S01", + "milestone_id": "M011", + "command": "`cd /home/pi/development/JobTracker && docker compose build frontend` succeeded.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:47:07.042Z" + }, + { + "id": 20, + "task_id": "T03", + "slice_id": "S01", + "milestone_id": "M011", + "command": "`cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false` produced 16 passing suites and 2 failing suites, leaving a clear follow-up list instead of an unverified baseline.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:47:07.042Z" + }, + { + "id": 21, + "task_id": "T01", + "slice_id": "S02", + "milestone_id": "M011", + "command": "`rg -n \"authToken|Authorization|Bearer|clearAuthToken|setAuthToken|getAuthToken|JwtBearer|TokenService|request-password-reset|login|logout\" job-tracker-ui/src JobTrackerApi -S` enumerated the frontend and API auth touchpoints.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:49:40.585Z" + }, + { + "id": 22, + "task_id": "T01", + "slice_id": "S02", + "milestone_id": "M011", + "command": "Reviewed `job-tracker-ui/src/auth.ts` and confirmed that localStorage/sessionStorage currently hold the app token.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:49:40.585Z" + }, + { + "id": 23, + "task_id": "T01", + "slice_id": "S02", + "milestone_id": "M011", + "command": "Reviewed `job-tracker-ui/src/api.ts` and confirmed the Authorization header is attached from browser storage on each request.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:49:40.585Z" + }, + { + "id": 24, + "task_id": "T01", + "slice_id": "S02", + "milestone_id": "M011", + "command": "Reviewed `job-tracker-ui/src/App.tsx`, `LoginPage.tsx`, `GoogleAuthCard.tsx`, `AuthStatusCard.tsx`, `UserManagementCard.tsx`, and `themePrefs.ts` to identify direct frontend dependencies on browser-stored JWT state.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:49:40.585Z" + }, + { + "id": 25, + "task_id": "T01", + "slice_id": "S02", + "milestone_id": "M011", + "command": "Reviewed `JobTrackerApi/Controllers/AuthController.cs`, `JobTrackerApi/Services/TokenService.cs`, and `JobTrackerApi/Program.cs` to confirm local app auth is currently JWT Bearer-based and returned directly to the frontend.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T16:49:40.585Z" + }, + { + "id": 26, + "task_id": "T02", + "slice_id": "S02", + "milestone_id": "M011", + "command": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter FullyQualifiedName~Auth", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 163, + "created_at": "2026-04-10T19:57:16.245Z" + }, + { + "id": 27, + "task_id": "T02", + "slice_id": "S02", + "milestone_id": "M011", + "command": "cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/login-page.test.tsx src/profile-page.test.tsx", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 15267, + "created_at": "2026-04-10T19:57:16.245Z" + }, + { + "id": 28, + "task_id": "T02", + "slice_id": "S02", + "milestone_id": "M011", + "command": "dotnet build JobTrackerApi/JobTrackerApi.csproj", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 5430, + "created_at": "2026-04-10T19:57:16.245Z" + }, + { + "id": 29, + "task_id": "T02", + "slice_id": "S02", + "milestone_id": "M011", + "command": "cd job-tracker-ui && npm run build", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 0, + "created_at": "2026-04-10T19:57:16.245Z" + }, + { + "id": 30, + "task_id": "T03", + "slice_id": "S02", + "milestone_id": "M011", + "command": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter FullyQualifiedName~Auth", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 163, + "created_at": "2026-04-10T19:57:41.019Z" + }, + { + "id": 31, + "task_id": "T03", + "slice_id": "S02", + "milestone_id": "M011", + "command": "cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/login-page.test.tsx src/profile-page.test.tsx", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 15267, + "created_at": "2026-04-10T19:57:41.019Z" + }, + { + "id": 32, + "task_id": "T03", + "slice_id": "S02", + "milestone_id": "M011", + "command": "cd job-tracker-ui && npm run build", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 0, + "created_at": "2026-04-10T19:57:41.019Z" + }, + { + "id": 33, + "task_id": "T03", + "slice_id": "S02", + "milestone_id": "M011", + "command": "Browser verification: navigating to http://localhost:3001/jobs redirected to /login with no failed requests in the observed pass.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T19:57:41.019Z" + }, + { + "id": 34, + "task_id": "T03", + "slice_id": "S02", + "milestone_id": "M011", + "command": "HTTP verification: GET /api/auth/csrf returned 204 and set the XSRF-TOKEN cookie; GET /api/auth/me with only that cookie returned 401 Unauthorized as expected for no session.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T19:57:41.019Z" + }, + { + "id": 35, + "task_id": "T01", + "slice_id": "S03", + "milestone_id": "M011", + "command": "rg -n \"catch\\(\\(\\) => \\[\\]\\)|catch\\(\\(\\) => set.*\\[\\]\\)|catch\\(\\(\\) => set.*null\\)|No jobs found|remindersNothing|companiesEmpty\" job-tracker-ui/src -S", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 0, + "created_at": "2026-04-10T22:05:25.917Z" + }, + { + "id": 36, + "task_id": "T02", + "slice_id": "S03", + "milestone_id": "M011", + "command": "cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/login-page.test.tsx src/profile-page.test.tsx", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 18627, + "created_at": "2026-04-10T22:19:14.244Z" + }, + { + "id": 37, + "task_id": "T02", + "slice_id": "S03", + "milestone_id": "M011", + "command": "cd job-tracker-ui && npm run build", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 0, + "created_at": "2026-04-10T22:19:14.244Z" + }, + { + "id": 38, + "task_id": "T03", + "slice_id": "S03", + "milestone_id": "M011", + "command": "cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/login-page.test.tsx src/profile-page.test.tsx", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 18627, + "created_at": "2026-04-10T22:19:33.191Z" + }, + { + "id": 39, + "task_id": "T03", + "slice_id": "S03", + "milestone_id": "M011", + "command": "cd job-tracker-ui && npm run build", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 0, + "created_at": "2026-04-10T22:19:33.191Z" + }, + { + "id": 40, + "task_id": "T03", + "slice_id": "S03", + "milestone_id": "M011", + "command": "Browser verification: with only the frontend running, navigating to http://localhost:3001/jobs showed 'Unable to load jobs' and 'The jobs list cannot reach the API right now.' instead of an empty jobs state.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T22:19:33.191Z" + }, + { + "id": 41, + "task_id": "T03", + "slice_id": "S03", + "milestone_id": "M011", + "command": "Browser verification: after the API auth surface was reachable again, navigating to http://localhost:3001/login showed the normal sign-in UI and remember-me controls.", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T22:19:33.191Z" + }, + { + "id": 42, + "task_id": "T01", + "slice_id": "S04", + "milestone_id": "M011", + "command": "rg -n \"AddHostedService|Migrate\\(|Ensure|RuleSettings|UserRuleSettings|JobApplications|Auth:|UseCors|UseRateLimiter|UseAuthentication|UseAuthorization\" JobTrackerApi/Program.cs JobTrackerApi/Services -S", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 0, + "created_at": "2026-04-10T22:33:06.539Z" + }, + { + "id": 43, + "task_id": "T02", + "slice_id": "S04", + "milestone_id": "M011", + "command": "dotnet build JobTrackerApi/JobTrackerApi.csproj", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 2750, + "created_at": "2026-04-10T22:44:02.945Z" + }, + { + "id": 44, + "task_id": "T02", + "slice_id": "S04", + "milestone_id": "M011", + "command": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter AuthAndSystemControllerTests", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 172, + "created_at": "2026-04-10T22:44:02.945Z" + }, + { + "id": 45, + "task_id": "T02", + "slice_id": "S04", + "milestone_id": "M011", + "command": "ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5202 dotnet run --project JobTrackerApi/JobTrackerApi.csproj", + "exit_code": 0, + "verdict": "✅ ready", + "duration_ms": 9000, + "created_at": "2026-04-10T22:44:02.945Z" + }, + { + "id": 46, + "task_id": "T03", + "slice_id": "S04", + "milestone_id": "M011", + "command": "dotnet build JobTrackerApi/JobTrackerApi.csproj", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 2750, + "created_at": "2026-04-10T22:44:23.635Z" + }, + { + "id": 47, + "task_id": "T03", + "slice_id": "S04", + "milestone_id": "M011", + "command": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter AuthAndSystemControllerTests", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 172, + "created_at": "2026-04-10T22:44:23.635Z" + }, + { + "id": 48, + "task_id": "T03", + "slice_id": "S04", + "milestone_id": "M011", + "command": "GET http://localhost:5202/api/auth/config", + "exit_code": 200, + "verdict": "✅ pass", + "duration_ms": 0, + "created_at": "2026-04-10T22:44:23.635Z" + }, + { + "id": 49, + "task_id": "T03", + "slice_id": "S04", + "milestone_id": "M011", + "command": "GET http://localhost:5202/api/auth/me", + "exit_code": 401, + "verdict": "✅ pass", + "duration_ms": 0, + "created_at": "2026-04-10T22:44:23.635Z" + }, + { + "id": 50, + "task_id": "T01", + "slice_id": "S05", + "milestone_id": "M011", + "command": "rg -n \"\\[Authorize|\\[AllowAnonymous|IFormFile|PhysicalFile|client-errors|backup|export|avatar\" JobTrackerApi/Controllers JobTrackerApi.Tests -S", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T22:56:09.918Z" + }, + { + "id": 51, + "task_id": "T02", + "slice_id": "S05", + "milestone_id": "M011", + "command": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AttachmentsControllerTests|FullyQualifiedName~BackupControllerTests|FullyQualifiedName~ClientErrorsControllerTests|FullyQualifiedName~AuthAndSystemControllerTests\"", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 825, + "created_at": "2026-04-10T22:59:48.937Z" + }, + { + "id": 52, + "task_id": "T02", + "slice_id": "S05", + "milestone_id": "M011", + "command": "dotnet build JobTrackerApi/JobTrackerApi.csproj", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 7920, + "created_at": "2026-04-10T22:59:48.937Z" + }, + { + "id": 53, + "task_id": "T03", + "slice_id": "S05", + "milestone_id": "M011", + "command": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AttachmentsControllerTests|FullyQualifiedName~BackupControllerTests|FullyQualifiedName~ClientErrorsControllerTests|FullyQualifiedName~AuthAndSystemControllerTests\"", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 825, + "created_at": "2026-04-10T23:00:30.313Z" + }, + { + "id": 54, + "task_id": "T03", + "slice_id": "S05", + "milestone_id": "M011", + "command": "dotnet build JobTrackerApi/JobTrackerApi.csproj", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 7920, + "created_at": "2026-04-10T23:00:30.313Z" + }, + { + "id": 55, + "task_id": "T03", + "slice_id": "S05", + "milestone_id": "M011", + "command": "GET http://localhost:5202/api/export/jobs", + "exit_code": 401, + "verdict": "✅ pass", + "duration_ms": 0, + "created_at": "2026-04-10T23:00:30.313Z" + }, + { + "id": 56, + "task_id": "T03", + "slice_id": "S05", + "milestone_id": "M011", + "command": "POST http://localhost:5202/api/backup/encrypted", + "exit_code": 401, + "verdict": "✅ pass", + "duration_ms": 0, + "created_at": "2026-04-10T23:00:30.313Z" + }, + { + "id": 57, + "task_id": "T03", + "slice_id": "S05", + "milestone_id": "M011", + "command": "GET http://localhost:5202/api/attachments/1", + "exit_code": 401, + "verdict": "✅ pass", + "duration_ms": 0, + "created_at": "2026-04-10T23:00:30.313Z" + }, + { + "id": 58, + "task_id": "T03", + "slice_id": "S05", + "milestone_id": "M011", + "command": "POST http://localhost:5202/api/client-errors", + "exit_code": 401, + "verdict": "✅ observed-constraint", + "duration_ms": 0, + "created_at": "2026-04-10T23:00:30.313Z" + }, + { + "id": 59, + "task_id": "T01", + "slice_id": "S06", + "milestone_id": "M011", + "command": "rg -n \"OLLAMA|SKIP_MODEL_LOAD|/health|/summarize|/extract-text|RunProbeAsync|GetMetricsAsync|LastError|Probe\" tools/summarizer JobTrackerApi/Services JobTrackerApi.Tests -S", + "exit_code": -1, + "verdict": "unknown (coerced from string)", + "duration_ms": 0, + "created_at": "2026-04-10T23:20:01.927Z" + }, + { + "id": 60, + "task_id": "T02", + "slice_id": "S06", + "milestone_id": "M011", + "command": "cd tools/summarizer && ./scripts/bootstrap-and-test.sh test", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 3090, + "created_at": "2026-04-10T23:24:03.785Z" + }, + { + "id": 61, + "task_id": "T02", + "slice_id": "S06", + "milestone_id": "M011", + "command": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AuthAndSystemControllerTests|FullyQualifiedName~ProductionConfigTests|FullyQualifiedName~OwnershipGuardTests\"", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 171, + "created_at": "2026-04-10T23:24:03.785Z" + }, + { + "id": 62, + "task_id": "T02", + "slice_id": "S06", + "milestone_id": "M011", + "command": "dotnet build JobTrackerApi/JobTrackerApi.csproj", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 3680, + "created_at": "2026-04-10T23:24:03.785Z" + }, + { + "id": 63, + "task_id": "T03", + "slice_id": "S06", + "milestone_id": "M011", + "command": "cd tools/summarizer && ./scripts/bootstrap-and-test.sh test", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 3090, + "created_at": "2026-04-10T23:24:23.066Z" + }, + { + "id": 64, + "task_id": "T03", + "slice_id": "S06", + "milestone_id": "M011", + "command": "dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter \"FullyQualifiedName~AuthAndSystemControllerTests|FullyQualifiedName~ProductionConfigTests|FullyQualifiedName~OwnershipGuardTests\"", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 171, + "created_at": "2026-04-10T23:24:23.066Z" + }, + { + "id": 65, + "task_id": "T03", + "slice_id": "S06", + "milestone_id": "M011", + "command": "dotnet build JobTrackerApi/JobTrackerApi.csproj", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 3680, + "created_at": "2026-04-10T23:24:23.066Z" + } + ] } \ No newline at end of file diff --git a/JobTrackerApi.Tests/AttachmentsControllerTests.cs b/JobTrackerApi.Tests/AttachmentsControllerTests.cs index 7d54420..0354dc2 100644 --- a/JobTrackerApi.Tests/AttachmentsControllerTests.cs +++ b/JobTrackerApi.Tests/AttachmentsControllerTests.cs @@ -1,12 +1,19 @@ using System.Reflection; using JobTrackerApi.Controllers; -using JobTrackerApi.Services; using Xunit; namespace JobTrackerApi.Tests; public sealed class AttachmentsControllerTests { + [Fact] + public void Controller_requires_local_authorization() + { + var attribute = typeof(AttachmentsController).GetCustomAttribute(); + Assert.NotNull(attribute); + Assert.Equal("local", attribute!.AuthenticationSchemes); + } + [Fact] public void Allowed_extensions_include_common_document_and_image_formats() { diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index b5a37b0..f28b553 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -91,13 +91,20 @@ public sealed class AuthAndSystemControllerTests .Setup(x => x.ValidateAsync("google-token", It.IsAny())) .ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones")); - var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), googleValidator.Object, NullLogger.Instance); + var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), googleValidator.Object, NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None); var ok = Assert.IsType(result.Result); - var payload = Assert.IsType(ok.Value); - Assert.Equal("app-token", payload.AccessToken); + var payload = Assert.IsType(ok.Value); + Assert.True(payload.Authenticated); + Assert.Equal("google", payload.Provider); Assert.Equal("google-subject", user.GoogleSubject); Assert.Equal("dj@cesnimda.co.uk", user.GoogleEmail); Assert.NotNull(user.GoogleLinkedAt); diff --git a/JobTrackerApi.Tests/BackupControllerTests.cs b/JobTrackerApi.Tests/BackupControllerTests.cs index 1223b7d..39f60a9 100644 --- a/JobTrackerApi.Tests/BackupControllerTests.cs +++ b/JobTrackerApi.Tests/BackupControllerTests.cs @@ -1,8 +1,10 @@ +using System.Reflection; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; @@ -12,6 +14,22 @@ namespace JobTrackerApi.Tests; public sealed class BackupControllerTests { + [Fact] + public void Backup_controller_requires_local_authorization() + { + var attribute = typeof(BackupController).GetCustomAttribute(); + Assert.NotNull(attribute); + Assert.Equal("local", attribute!.AuthenticationSchemes); + } + + [Fact] + public void Export_controller_requires_local_authorization() + { + var attribute = typeof(ExportController).GetCustomAttribute(); + Assert.NotNull(attribute); + Assert.Equal("local", attribute!.AuthenticationSchemes); + } + [Fact] public async Task Encrypted_returns_file_payload_on_non_windows_platforms_too() { diff --git a/JobTrackerApi.Tests/ClientErrorsControllerTests.cs b/JobTrackerApi.Tests/ClientErrorsControllerTests.cs new file mode 100644 index 0000000..eecfa6d --- /dev/null +++ b/JobTrackerApi.Tests/ClientErrorsControllerTests.cs @@ -0,0 +1,103 @@ +using System.Security.Claims; +using System.Text; +using JobTrackerApi.Controllers; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class ClientErrorsControllerTests +{ + [Fact] + public void Report_logs_sanitized_payload_instead_of_raw_stacks() + { + var logger = new ListLogger(); + var controller = new ClientErrorsController(logger); + var stack = "TypeError: bad\n at render(App.tsx:10)\nextra-secret-line"; + var componentStack = "at Widget\n at Dashboard"; + + var result = controller.Report(new ClientErrorsController.ClientErrorReport( + ErrorId: " err-1 ", + Message: " boom ", + Stack: stack, + ComponentStack: componentStack, + Url: " https://jobtracker.test/jobs ", + UserAgent: " Browser\nAgent ", + At: " 2026-04-10T18:00:00Z ")); + + Assert.IsType(result); + var entry = Assert.Single(logger.Entries); + Assert.Contains("stackHash=", entry.Message); + Assert.Contains("componentHash=", entry.Message); + Assert.Contains("TypeError: bad | at render(App.tsx:10)", entry.Message); + Assert.DoesNotContain(stack, entry.Message); + Assert.DoesNotContain(componentStack, entry.Message); + Assert.DoesNotContain("extra-secret-line", entry.Message); + Assert.DoesNotContain("Browser\nAgent", entry.Message); + } + + [Fact] + public async Task Upload_avatar_rejects_file_when_extension_or_detected_bytes_are_not_supported() + { + var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" }; + var userManager = TestHostFactory.CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of>()) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") }, "local")) } + } + }; + + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes("not really a png")); + IFormFile file = new FormFile(stream, 0, stream.Length, "file", "avatar.png") + { + Headers = new HeaderDictionary(), + ContentType = "image/png" + }; + + var result = await controller.UploadAvatar(file); + + var badRequest = Assert.IsType(result); + Assert.Equal("Only PNG, JPEG, or WebP images are supported.", badRequest.Value); + userManager.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + + private static IConfiguration BuildConfig() + { + return new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + } + + private sealed class ListLogger : ILogger + { + public List Entries { get; } = new(); + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Entries.Add(new LogEntry(logLevel, formatter(state, exception))); + } + } + + private sealed record LogEntry(LogLevel Level, string Message); + + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + public void Dispose() { } + } +} diff --git a/JobTrackerApi.Tests/CvCorpusHarnessTests.cs b/JobTrackerApi.Tests/CvCorpusHarnessTests.cs index e9ced3c..bafb37a 100644 --- a/JobTrackerApi.Tests/CvCorpusHarnessTests.cs +++ b/JobTrackerApi.Tests/CvCorpusHarnessTests.cs @@ -1,3 +1,4 @@ +using System.IO.Enumeration; using System.Reflection; using System.Security.Cryptography; using System.Text.Json; @@ -24,11 +25,13 @@ public sealed class CvCorpusHarnessTests { if (!Directory.Exists(CorpusRoot)) return; + var ignoredPatterns = ResolveIgnoredPatterns(); var files = Directory.EnumerateFiles(CorpusRoot, "*.*", SearchOption.TopDirectoryOnly) .Where(path => path.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + .Where(path => !IsIgnoredFile(path, ignoredPatterns)) .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -51,17 +54,20 @@ public sealed class CvCorpusHarnessTests var aiService = new Mock(); aiService.Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)).ReturnsAsync(string.Empty); aiService.Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Reconstruct this CV text extracted from a PDF", StringComparison.Ordinal)), It.IsAny(), 2800, 900)).ReturnsAsync((string _, string text, int _, int __) => text); + var cvAiNormalizer = CreateCvAiNormalizerFromEnvironment(); await using var db = TestHostFactory.CreateInMemoryDb(); var paths = CreatePaths(outputRoot); - var controller = new ProfileCvController(userManager.Object, aiService.Object, db, paths, null, NoOpCvAiClassifier.Instance) + var controller = new ProfileCvController(userManager.Object, aiService.Object, db, paths, null, NoOpCvAiClassifier.Instance, cvAiNormalizer) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var extractMethod = typeof(ProfileCvController).GetMethod("ExtractTextAsync", BindingFlags.NonPublic | BindingFlags.Static); + var reconstructMethod = typeof(ProfileCvController).GetMethod("MaybeReconstructStructuredCvAsync", BindingFlags.NonPublic | BindingFlags.Instance); var buildMethod = typeof(ProfileCvController).GetMethod("BuildStructuredCvAsync", BindingFlags.NonPublic | BindingFlags.Instance); Assert.NotNull(extractMethod); + Assert.NotNull(reconstructMethod); Assert.NotNull(buildMethod); var entries = new List(); @@ -80,7 +86,11 @@ public sealed class CvCorpusHarnessTests var text = await extractTask; Assert.False(string.IsNullOrWhiteSpace(text)); - var buildTask = (Task)buildMethod!.Invoke(controller, new object[] { text, CancellationToken.None })!; + var reconstructTask = (Task)reconstructMethod!.Invoke(controller, new object[] { text, CancellationToken.None })!; + var normalizedText = await reconstructTask; + Assert.False(string.IsNullOrWhiteSpace(normalizedText)); + + var buildTask = (Task)buildMethod!.Invoke(controller, new object[] { normalizedText, CancellationToken.None })!; var structured = StructuredCvProfileJson.Normalize(await buildTask); Assert.NotNull(structured); @@ -199,6 +209,33 @@ public sealed class CvCorpusHarnessTests return Path.Combine(outputRoot, "approved-fixtures"); } + private static List ResolveIgnoredPatterns() + { + var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_IGNORE"); + if (string.IsNullOrWhiteSpace(configured)) return new List(); + + return configured + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToList(); + } + + private static bool IsIgnoredFile(string path, List ignoredPatterns) + { + if (ignoredPatterns.Count == 0) return false; + + var fileName = Path.GetFileName(path); + foreach (var pattern in ignoredPatterns) + { + if (FileSystemName.MatchesSimpleExpression(pattern, fileName, ignoreCase: true)) + { + return true; + } + } + + return false; + } + private static string PrettyJson(string normalizedJson) { using var doc = JsonDocument.Parse(normalizedJson); @@ -327,4 +364,20 @@ public sealed class CvCorpusHarnessTests env.SetupGet(x => x.ContentRootPath).Returns(tempRoot); return new AppPaths(config, env.Object); } + + private static ICvAiNormalizer CreateCvAiNormalizerFromEnvironment() + { + var baseUrl = Environment.GetEnvironmentVariable("CV_AI_BASE_URL"); + if (string.IsNullOrWhiteSpace(baseUrl)) return NoOpCvAiNormalizer.Instance; + + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + services.AddHttpClient("ai-service", client => + { + client.BaseAddress = new Uri(baseUrl.Trim()); + client.Timeout = TimeSpan.FromSeconds(180); + }); + var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + return new CvAiNormalizer(factory); + } } diff --git a/JobTrackerApi.Tests/ProfileCvControllerTests.cs b/JobTrackerApi.Tests/ProfileCvControllerTests.cs index 5492365..3f90d1c 100644 --- a/JobTrackerApi.Tests/ProfileCvControllerTests.cs +++ b/JobTrackerApi.Tests/ProfileCvControllerTests.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; @@ -134,6 +135,7 @@ public sealed class ProfileCvControllerTests var user = new ApplicationUser { Id = "user-1", CurrentCvProfileVersion = 1 }; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var aiService = new Mock(); aiService @@ -176,7 +178,12 @@ public sealed class ProfileCvControllerTests var controller = CreateController(userManager.Object, aiService.Object, db, paths); var result = await controller.Reprocess(); - Assert.IsType(result); + var accepted = Assert.IsType(result); + var queuedRun = await db.CvExtractionRuns.SingleAsync(); + Assert.Equal("queued", queuedRun.Status); + + await controller.ProcessQueuedRunAsync(queuedRun.Id, CancellationToken.None); + var run = await db.CvExtractionRuns.SingleAsync(); Assert.Equal("reprocess", run.Trigger); Assert.Equal("applied", run.Status); @@ -276,6 +283,55 @@ public sealed class ProfileCvControllerTests Assert.Contains(structured.Sections, section => section.Name == "Education"); } + [Fact] + public async Task Upload_uses_ai_normalizer_fallback_when_flattened_text_stays_low_structure() + { + var rawExtraction = "connor.babbington@cesnimda.co.uk cesnimda.co.uk +47 41 33 44 70 E X P E R I E N C E S Y S T E M D E V E L O P E R 2015 - 2023 Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript. + Warwickshire County Council, UK"; + + var user = new ApplicationUser { Id = "user-1" }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + var aiService = new Mock(); + aiService + .Setup(x => x.ExtractTextAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AiTextExtractionResult(rawExtraction, false, "application/pdf", 1, rawExtraction.Length, "Resume.en.pdf")); + aiService + .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Reconstruct this CV text extracted from a PDF", StringComparison.Ordinal)), rawExtraction, 2800, 900)) + .ReturnsAsync(string.Empty); + aiService + .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)) + .ReturnsAsync("not-json"); + + var normalizer = new Mock(); + normalizer + .Setup(x => x.NormalizeAsync(It.Is(text => text.Contains("Warwickshire County Council", StringComparison.Ordinal)), It.IsAny())) + .ReturnsAsync(new CvNormalizationResult( + 0.91, + "Recovered structured sections from flattened OCR text", + "# Contact\nConnor Babbington\nconnor.babbington@cesnimda.co.uk\n+47 41 33 44 70\ncesnimda.co.uk\n\n# Professional Summary\nMid-level system developer with eight years of experience in UK local government.\n\n# Work Experience\nSystem Developer\nWarwickshire County Council, UK\n2015 - 2023\n- Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript.\n\n# Skills\nC#\nPython\nRuby on Rails\nSQL\nJavaScript")); + + await using var db = CreateDb(); + var paths = CreatePaths(); + var controller = CreateController(userManager.Object, aiService.Object, db, paths, null, normalizer.Object); + + var bytes = Encoding.UTF8.GetBytes("fake pdf bytes"); + var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "Resume.en.pdf") + { + Headers = new HeaderDictionary(), + ContentType = "application/pdf" + }; + + var result = await controller.Upload(file); + + Assert.IsType(result); + normalizer.Verify(x => x.NormalizeAsync(It.Is(text => text.Contains("Warwickshire County Council", StringComparison.Ordinal)), It.IsAny()), Times.Once); + var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + Assert.Equal("Connor Babbington", structured.Contact.FullName); + Assert.Contains("# Skills", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Warwickshire County Council", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Upload_populates_structured_fields_from_flattened_cv_when_ai_json_is_invalid() { @@ -790,9 +846,248 @@ public sealed class ProfileCvControllerTests Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Contact.FullName); } - private static ProfileCvController CreateController(UserManager userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ICvAiClassifier? cvAiClassifier = null) + [Fact] + public void Normalized_markdown_parse_preserves_real_estate_job_and_language_levels() { - return new ProfileCvController(userManager, aiService, db, paths, null, cvAiClassifier ?? NoOpCvAiClassifier.Instance) + var normalized = "# Contact\nAvery Cooper\n(415) 223-4344\nhttps://www.linkedin.com/in/avery-cooper/\nhttps://www.realtor.com/realestateagents/avery-copper/\nSan Francisco\n\n# Professional Summary\nDynamic real estate professional with 12 years of experience in residential and commercial property.\n\n# Work Experience\nReal Estate Agent\nEleanor Lane Agency, White Plains\nJuly 2017 - Present\n- Managed all aspects of the sales process from preparation to close, achieving a 25% increase in closed deals compared to previous periods.\n- Successfully negotiated favorable terms for clients in over 50 real estate transactions, consistently securing above-market value.\n\nReal Estate Assistant\nHathaway Properties, New Rochelle\nOctober 2012 - June 2017\n- Managed administrative tasks in a fast-paced real estate office, ensuring smooth daily operations.\n- Supported Realtors and Brokers by coordinating marketing materials, client communications, and office transactions.\n\n# Skills\n- Contract Management\n- Retail Market Analysis\n- Property Valuation\n- Client Relationship Management\n- Digital Marketing\n- Attention to Detail\n\n# Languages\n- English (Native)\n- Spanish - C2"; + + var actual = ParseNormalizedMarkdown(normalized); + + Assert.Equal("Avery Cooper", actual.Contact.FullName); + Assert.Equal("San Francisco", actual.Contact.Location); + Assert.NotEmpty(actual.Jobs); + Assert.Equal("Real Estate Agent", actual.Jobs[0].Title); + Assert.Equal("Eleanor Lane Agency, White Plains", actual.Jobs[0].Company); + Assert.True(actual.Jobs[0].Bullets.Count >= 2); + Assert.Contains("Contract Management", actual.Skills); + Assert.Contains(actual.Languages, item => string.Equals(item.Name, "Spanish", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "C2", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Normalized_markdown_parse_preserves_web_developer_bullets_and_skills() + { + var normalized = "# Contact\nChristoper Morgan\nchristoper.m@gmail.com\n+44 (0)20 7666 8555\n\n# Professional Summary\nSenior Web Developer specializing in front end development. Experienced with all stages of the development cycle for dynamic web projects.\n\n# Work Experience\nWeb Developer\nLuna Web Design, New York\n09/2015 - 05/2019\n- Cooperate with designers to create clean interfaces and simple, intuitive interactions and experiences.\n- Develop project concepts and maintain optimal workflow.\n- Work with senior developer to manage large, complex design projects for corporate clients.\n- Complete detailed programming and development tasks for front end public and internal websites as well as challenging back-end server code.\n- Carry out quality assurance tests to discover errors and optimize usability.\n\n# Skills\n- JavaScript\n- HTML5\n- PHP OOP\n- CSS\n- SQL\n- MySQL\n\n# Languages\n- Spanish - C2\n- Chinese - A1\n- German - A2"; + + var actual = ParseNormalizedMarkdown(normalized); + + Assert.Equal("Christoper Morgan", actual.Contact.FullName); + Assert.NotEmpty(actual.Jobs); + Assert.Equal("Web Developer", actual.Jobs[0].Title); + Assert.Equal("Luna Web Design, New York", actual.Jobs[0].Company); + Assert.True(actual.Jobs[0].Bullets.Count >= 5); + Assert.Contains("JavaScript", actual.Skills); + Assert.Contains("MySQL", actual.Skills); + Assert.Contains(actual.Languages, item => string.Equals(item.Name, "Chinese", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "A1", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(actual.Languages, item => string.Equals(item.Name, "German", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "A2", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Parse_uses_forced_ai_normalizer_output_when_enabled() + { + var previous = Environment.GetEnvironmentVariable("CV_FORCE_AI_NORMALIZER"); + Environment.SetEnvironmentVariable("CV_FORCE_AI_NORMALIZER", "true"); + try + { + var source = "Avery CooperReal Estate Agent\nSan Francisco(415) 223-4344\nDynamic real estate professional with 12 years of experience in residential and commercial property."; + var user = new ApplicationUser { Id = "user-1", ProfileCvText = source }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + var aiService = new Mock(); + aiService.Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)) + .ReturnsAsync("not-json"); + + var normalizer = new Mock(); + normalizer + .Setup(x => x.NormalizeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new CvNormalizationResult( + 0.88, + "forced test", + "# Contact\nAvery Cooper\n(415) 223-4344\nhttps://www.linkedin.com/in/avery-cooper/\nhttps://www.realtor.com/realestateagents/avery-copper/\nSan Francisco\n\n# Professional Summary\nDynamic real estate professional with 12 years of experience in residential and commercial property.\n\n# Work Experience\nReal Estate Agent\nEleanor Lane Agency, White Plains\nJuly 2017 - Present\n- Managed all aspects of the sales process from preparation to close.\n\n# Skills\n- Contract Management\n- Property Valuation\n\n# Languages\n- English (Native)\n- Spanish - C2")); + + await using var db = CreateDb(); + var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths(), null, normalizer.Object); + var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + Assert.IsType(result.Result); + + var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + Assert.Equal("Avery Cooper", actual.Contact.FullName); + Assert.Equal("San Francisco", actual.Contact.Location); + Assert.NotEmpty(actual.Jobs); + Assert.Contains("Real Estate Agent", actual.Jobs[0].Title ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Contract Management", actual.Skills); + Assert.Contains(actual.Languages, item => string.Equals(item.Name, "Spanish", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "C2", StringComparison.OrdinalIgnoreCase)); + } + finally + { + Environment.SetEnvironmentVariable("CV_FORCE_AI_NORMALIZER", previous); + } + } + + [Fact] + public async Task Approved_fixture_regression_for_cv_txt_keeps_core_fields_stable() + { + var approvedPath = "/home/pi/cvs/approved-jsons/cv-txt.json"; + var rawPath = "/home/pi/cvs/cv.txt"; + if (!System.IO.File.Exists(approvedPath) || !System.IO.File.Exists(rawPath)) return; + + var approved = StructuredCvProfileJson.Deserialize(await System.IO.File.ReadAllTextAsync(approvedPath)); + var rawSource = await System.IO.File.ReadAllTextAsync(rawPath); + var user = new ApplicationUser { Id = "user-1", ProfileCvText = rawSource }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + var aiService = new Mock(); + aiService + .Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)) + .ReturnsAsync("not-json"); + + await using var db = CreateDb(); + var paths = CreatePaths(); + var controller = CreateController(userManager.Object, aiService.Object, db, paths); + + var result = await controller.Parse(new ProfileCvController.ParseCvRequest(rawSource)); + var ok = Assert.IsType(result.Result); + Assert.NotNull(ok.Value); + + var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + Assert.Equal(approved.Contact.FullName, actual.Contact.FullName); + Assert.Equal(approved.Contact.Location, actual.Contact.Location); + Assert.True(actual.Skills.Count >= 2); + } + + [Fact] + public async Task Approved_fixture_regression_for_new_resume_docx_keeps_contact_and_role_core_fields_stable() + { + var approvedPath = "/home/pi/cvs/approved-jsons/new-resume-001-docx.json"; + if (!System.IO.File.Exists(approvedPath)) return; + + var approved = StructuredCvProfileJson.Deserialize(await System.IO.File.ReadAllTextAsync(approvedPath)); + var source = "Christoper Morgan\nPhone: +49 800 600 600\nE-Mail: christoper.morgan@gmail.com\nLinkedin: linkedin.com/christopher.morgan\n\nSkill Highlights\nProject management\nStrong decision maker\nComplex problem solver\nCreative design\nInnovative\nService-focused\n\n09/2015 to 05/2019\nWeb Developer\nLuna Web Design, New York\nCooperate with designers to create clean interfaces and simple, intuitive interactions and experiences.\nDevelop project concepts and maintain optimal workflow.\nWork with senior developer to manage large, complex design projects for corporate clients.\nComplete detailed programming and development tasks for front end public and internal websites as well as challenging back-end server code.\nCarry out quality assurance tests to discover errors and optimize usability.\n\n2014 to 2019\nBachelor Of Science: Computer Information Systems\nColumbia University, NY\n\nLanguages\nSpanish C2\nChinese C2\n\nSkills\nJavaScript\nSQL"; + + var user = new ApplicationUser { Id = "user-1", ProfileCvText = source }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + var aiService = new Mock(); + aiService.Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)) + .ReturnsAsync("not-json"); + + await using var db = CreateDb(); + var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); + var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + Assert.IsType(result.Result); + + var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + Assert.Equal(approved.Contact.FullName, actual.Contact.FullName); + Assert.Equal(approved.Contact.Email, actual.Contact.Email); + Assert.NotEmpty(actual.Jobs); + Assert.Contains("Web Developer", actual.Jobs[0].Title ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("JavaScript", actual.Skills); + Assert.Contains("SQL", actual.Skills); + } + + [Fact] + public async Task Approved_fixture_regression_for_coolfreecv_resume_keeps_summary_and_bullets_stable() + { + var approvedPath = "/home/pi/cvs/approved-jsons/coolfreecv-resume-en-03-n-docx.json"; + if (!System.IO.File.Exists(approvedPath)) return; + + var approved = StructuredCvProfileJson.Deserialize(await System.IO.File.ReadAllTextAsync(approvedPath)); + var source = "Christoper Morgan\nchristoper.m@gmail.com\n+44 (0)20 7666 8555\n\nSenior Web Developer specializing in front end development. Experienced with all stages of the development cycle for dynamic web projects. Well-versed in numerous programming languages including HTML5, PHP OOP, JavaScript, CSS, MySQL. Strong background in project management and customer relations.\n\nWeb Developer - 09/2015 to 05/2019\nLuna Web Design, New York\nCooperate with designers to create clean interfaces and simple, intuitive interactions and experiences.\nDevelop project concepts and maintain optimal workflow.\nWork with senior developer to manage large, complex design projects for corporate clients.\nComplete detailed programming and development tasks for front end public and internal websites as well as challenging back-end server code.\nCarry out quality assurance tests to discover errors and optimize usability.\n\nBachelor Of Science: Computer Information Systems - 2014\nColumbia University, NY\n\nSkills\nJavaScript, HTML5, PHP OOP, CSS, SQL, MySQL\nProject management\nStrong decision maker\nComplex problem solver\nCreative design\nInnovative\nService-focused\n\nLanguages\nSpanish C2\nChinese A1\nGerman A2"; + + var user = new ApplicationUser { Id = "user-1", ProfileCvText = source }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + var aiService = new Mock(); + aiService.Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)) + .ReturnsAsync("not-json"); + + await using var db = CreateDb(); + var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); + var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + Assert.IsType(result.Result); + + var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + Assert.Equal(approved.Contact.FullName, actual.Contact.FullName); + Assert.Equal(approved.Contact.Email, actual.Contact.Email); + Assert.NotEmpty(actual.Summary); + Assert.Contains("Senior Web Developer", actual.Summary[0], StringComparison.OrdinalIgnoreCase); + Assert.NotEmpty(actual.Jobs); + Assert.Contains("Web Developer", actual.Jobs[0].Title ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("JavaScript", actual.Skills); + Assert.Contains("MySQL", actual.Skills); + } + + [Fact] + public async Task Deterministic_parse_handles_flat_resume_contact_and_first_job() + { + var source = "Christoper Morgan\nchristoper.m@gmail.com\n+44 (0)20 7666 8555\nSenior Web Developer specializing in front end development. Experienced with all stages of the development cycle for dynamic web projects.\n\nWeb Developer - 09/2015 to 05/2019\nLuna Web Design, New York\nCooperate with designers to create clean interfaces and simple, intuitive interactions and experiences.\nDevelop project concepts and maintain optimal workflow.\nWork with senior developer to manage large, complex design projects for corporate clients.\nComplete detailed programming and development tasks for front end public and internal websites as well as challenging back-end server code.\nCarry out quality assurance tests to discover errors and optimize usability.\n\nSkills\nJavaScript, HTML5, PHP OOP, CSS, SQL, MySQL"; + + var user = new ApplicationUser { Id = "user-1", ProfileCvText = source }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + var aiService = new Mock(); + aiService.Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)) + .ReturnsAsync("not-json"); + + await using var db = CreateDb(); + var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); + var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + Assert.IsType(result.Result); + + var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + Assert.Equal("Christoper Morgan", actual.Contact.FullName); + Assert.Equal("christoper.m@gmail.com", actual.Contact.Email); + Assert.Equal("+44 (0)20 7666 8555", actual.Contact.Phone); + Assert.NotEmpty(actual.Jobs); + Assert.Contains("Web Developer", actual.Jobs[0].Title ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("JavaScript", actual.Skills); + Assert.Contains("SQL", actual.Skills); + } + + [Fact] + public async Task Deterministic_parse_handles_real_estate_contact_summary_and_jobs() + { + var source = "Avery Cooper Real Estate Agent\n(415) 223-4344\nSan Francisco\nhttps://www.linkedin.com/in/avery-cooper\nhttps://www.realtor.com/realestateagents/avery-copper/\n\nDynamic real estate professional with 12 years of experience in residential and commercial property. Proven track record in developing strong client relationships, closing over 50 successful deals, and providing exceptional real estate experiences.\n\nReal Estate Agent at Eleanor Lane Agency\nWhite Plains\n2017 - Present\nManaged all aspects of the sales process from preparation to close, achieving a 25% increase in closed deals compared to previous periods.\nSuccessfully negotiated favorable terms for clients in over 50 real estate transactions, consistently securing above-market value.\n\nReal Estate Assistant at Hathaway Properties\nNew Rochelle\n2012 - 2017\nManaged administrative tasks in a fast-paced real estate office, ensuring smooth daily operations.\nSupported Realtors and Brokers by coordinating marketing materials, client communications, and office transactions.\n\nSkills\nContract Management, Retail Market Analysis, Property Valuation, Client Relationship Management, Digital Marketing, Attention to Detail"; + + var user = new ApplicationUser { Id = "user-1", ProfileCvText = source }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + var aiService = new Mock(); + aiService.Setup(x => x.SummarizeSectionAsync(It.Is(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)) + .ReturnsAsync("not-json"); + + await using var db = CreateDb(); + var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths()); + var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source)); + Assert.IsType(result.Result); + + var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson); + Assert.Equal("Avery Cooper", actual.Contact.FullName); + Assert.Equal("San Francisco", actual.Contact.Location); + Assert.Contains("realtor.com", actual.Contact.Website ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.NotEmpty(actual.Summary); + Assert.True(actual.Jobs.Count >= 2); + Assert.Contains("Contract Management", actual.Skills); + Assert.Contains("Attention to Detail", actual.Skills); + } + + private static StructuredCvProfile ParseNormalizedMarkdown(string normalized) + { + var method = typeof(ProfileCvController).GetMethod("BuildStructuredCvFromNormalizedMarkdown", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.NotNull(method); + var result = method!.Invoke(null, new object[] { normalized }); + Assert.NotNull(result); + return StructuredCvProfileJson.Normalize((StructuredCvProfile)result!); + } + + private static ProfileCvController CreateController(UserManager userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null) + { + return new ProfileCvController(userManager, aiService, db, paths, null, cvAiClassifier ?? NoOpCvAiClassifier.Instance, cvAiNormalizer ?? NoOpCvAiNormalizer.Instance) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; diff --git a/JobTrackerApi/Controllers/AttachmentsController.cs b/JobTrackerApi/Controllers/AttachmentsController.cs index e7c3454..b8942aa 100644 --- a/JobTrackerApi/Controllers/AttachmentsController.cs +++ b/JobTrackerApi/Controllers/AttachmentsController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using JobTrackerApi.Data; using JobTrackerApi.Models; @@ -9,6 +10,7 @@ namespace JobTrackerApi.Controllers { [ApiController] [Route("api/attachments")] + [Authorize(AuthenticationSchemes = "local")] public class AttachmentsController : ControllerBase { private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB per file keeps local storage use predictable. diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index 11f6cb6..52c02c4 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -5,6 +5,7 @@ using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Controllers; @@ -47,9 +48,9 @@ public sealed class AuthController : ControllerBase }); } - public sealed record LoginRequest(string Email, string Password); - public sealed record RegisterRequest(string Email, string Password); - public sealed record AuthResult(string AccessToken, string TokenType); + public sealed record LoginRequest(string Email, string Password, bool RememberMe = true); + public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true); + public sealed record AuthSessionResult(bool Authenticated, string Provider); public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt); public sealed record MeResult( string Provider, @@ -64,12 +65,18 @@ public sealed class AuthController : ControllerBase string? AvatarImageDataUrl, IList Roles, GoogleLinkDto? GoogleLink); + private const int MaxAvatarBytes = 1_000_000; + private static readonly HashSet AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".webp" + }; public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson); - public sealed record GoogleTokenRequest(string Token); + public sealed record GoogleTokenRequest(string Token, bool RememberMe = true); [HttpPost("login")] [AllowAnonymous] - public async Task> Login([FromBody] LoginRequest request, CancellationToken cancellationToken) + [EnableRateLimiting("auth-login")] + public async Task> Login([FromBody] LoginRequest request, CancellationToken cancellationToken) { var email = (request.Email ?? string.Empty).Trim(); var password = request.Password ?? string.Empty; @@ -83,13 +90,14 @@ public sealed class AuthController : ControllerBase var ok = await _users.CheckPasswordAsync(user, password); if (!ok) return Unauthorized(); - var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken); - return Ok(new AuthResult(token, "Bearer")); + await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken); + return Ok(new AuthSessionResult(true, "local")); } [HttpPost("register")] [AllowAnonymous] - public async Task> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken) + [EnableRateLimiting("auth-login")] + public async Task> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken) { var allow = _cfg.GetValue("Auth:AllowRegistration", false); if (!allow) return StatusCode(403, "Registration is disabled."); @@ -110,13 +118,14 @@ public sealed class AuthController : ControllerBase return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); } - var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken); - return Ok(new AuthResult(token, "Bearer")); + await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken); + return Ok(new AuthSessionResult(true, "local")); } [HttpPost("google/exchange")] [AllowAnonymous] - public async Task> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken) + [EnableRateLimiting("auth-login")] + public async Task> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken) { var token = (request.Token ?? string.Empty).Trim(); if (token.Length == 0) return BadRequest("Google token is required."); @@ -160,8 +169,23 @@ public sealed class AuthController : ControllerBase await _users.UpdateAsync(user); } - var appToken = await _tokens.CreateAccessTokenAsync(user, cancellationToken); - return Ok(new AuthResult(appToken, "Bearer")); + await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken); + return Ok(new AuthSessionResult(true, "google")); + } + + [HttpPost("logout")] + public IActionResult Logout() + { + ClearSessionCookies(); + return NoContent(); + } + + [HttpGet("csrf")] + [AllowAnonymous] + public IActionResult EnsureCsrfCookie() + { + EnsureCsrfCookie(false); + return NoContent(); } [HttpGet("me")] @@ -300,7 +324,7 @@ public sealed class AuthController : ControllerBase [HttpPost("avatar")] [Authorize(AuthenticationSchemes = "local")] - [RequestSizeLimit(5_000_000)] + [RequestSizeLimit(MaxAvatarBytes)] public async Task UploadAvatar([FromForm] IFormFile? file) { var user = await _users.GetUserAsync(User); @@ -314,24 +338,30 @@ public sealed class AuthController : ControllerBase return BadRequest("Image file is required."); } - if (!string.Equals(file.ContentType, "image/png", StringComparison.OrdinalIgnoreCase) - && !string.Equals(file.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase) - && !string.Equals(file.ContentType, "image/webp", StringComparison.OrdinalIgnoreCase)) - { - return BadRequest("Only PNG, JPEG, or WebP images are supported."); - } - - if (file.Length > 5_000_000) + if (file.Length > MaxAvatarBytes) { return BadRequest("Avatar image is too large."); } + var extension = Path.GetExtension(file.FileName ?? string.Empty); + if (!AllowedAvatarExtensions.Contains(extension)) + { + return BadRequest("Only PNG, JPEG, or WebP images are supported."); + } + await using var stream = file.OpenReadStream(); using var memory = new MemoryStream(); await stream.CopyToAsync(memory); var bytes = memory.ToArray(); + + var detectedContentType = DetectAvatarContentType(bytes); + if (detectedContentType is null) + { + return BadRequest("Only PNG, JPEG, or WebP images are supported."); + } + var base64 = Convert.ToBase64String(bytes); - user.AvatarImageDataUrl = $"data:{file.ContentType};base64,{base64}"; + user.AvatarImageDataUrl = $"data:{detectedContentType};base64,{base64}"; var result = await _users.UpdateAsync(user); if (!result.Succeeded) @@ -388,6 +418,7 @@ public sealed class AuthController : ControllerBase [HttpPost("request-password-reset")] [AllowAnonymous] + [EnableRateLimiting("auth-email")] public async Task RequestPasswordReset([FromBody] RequestPasswordResetRequest request, CancellationToken cancellationToken) { var email = (request.Email ?? string.Empty).Trim(); @@ -431,6 +462,7 @@ public sealed class AuthController : ControllerBase [HttpPost("reset-password")] [AllowAnonymous] + [EnableRateLimiting("auth-email")] public async Task ResetPassword([FromBody] ResetPasswordRequest request) { var email = (request.Email ?? string.Empty).Trim(); @@ -456,6 +488,67 @@ public sealed class AuthController : ControllerBase return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail); } + private async Task SignInWithAppSessionAsync(ApplicationUser user, bool rememberMe, CancellationToken cancellationToken) + { + var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken); + var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase); + Response.Cookies.Append(AuthSessionOptions.SessionCookieName, token, AuthSessionOptions.BuildSessionCookie(rememberMe, secure)); + EnsureCsrfCookie(rememberMe, secure); + } + + private void EnsureCsrfCookie(bool persistent, bool? secureOverride = null) + { + var secure = secureOverride ?? Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase); + var csrf = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + Response.Cookies.Append(AuthSessionOptions.CsrfCookieName, csrf, AuthSessionOptions.BuildCsrfCookie(persistent, secure)); + } + + private void ClearSessionCookies() + { + var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase); + Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(secure)); + Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(secure)); + } + + private static string? DetectAvatarContentType(byte[] bytes) + { + if (bytes.Length >= 8 + && bytes[0] == 0x89 + && bytes[1] == 0x50 + && bytes[2] == 0x4E + && bytes[3] == 0x47 + && bytes[4] == 0x0D + && bytes[5] == 0x0A + && bytes[6] == 0x1A + && bytes[7] == 0x0A) + { + return "image/png"; + } + + if (bytes.Length >= 3 + && bytes[0] == 0xFF + && bytes[1] == 0xD8 + && bytes[2] == 0xFF) + { + return "image/jpeg"; + } + + if (bytes.Length >= 12 + && bytes[0] == 0x52 + && bytes[1] == 0x49 + && bytes[2] == 0x46 + && bytes[3] == 0x46 + && bytes[8] == 0x57 + && bytes[9] == 0x45 + && bytes[10] == 0x42 + && bytes[11] == 0x50) + { + return "image/webp"; + } + + return null; + } + private static string? TrimOrNull(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); diff --git a/JobTrackerApi/Controllers/BackupController.cs b/JobTrackerApi/Controllers/BackupController.cs index 1d19379..d1d820e 100644 --- a/JobTrackerApi/Controllers/BackupController.cs +++ b/JobTrackerApi/Controllers/BackupController.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.Json; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -9,6 +10,7 @@ namespace JobTrackerApi.Controllers { [ApiController] [Route("api/backup")] + [Authorize(AuthenticationSchemes = "local")] public class BackupController : ControllerBase { private readonly JobTrackerContext _db; diff --git a/JobTrackerApi/Controllers/ClientErrorsController.cs b/JobTrackerApi/Controllers/ClientErrorsController.cs index 1c9b860..93352a5 100644 --- a/JobTrackerApi/Controllers/ClientErrorsController.cs +++ b/JobTrackerApi/Controllers/ClientErrorsController.cs @@ -1,11 +1,17 @@ +using System.Security.Cryptography; +using System.Text; using Microsoft.AspNetCore.Mvc; namespace JobTrackerApi.Controllers { [ApiController] [Route("api/client-errors")] + [RequestSizeLimit(32 * 1024)] public class ClientErrorsController : ControllerBase { + private const int MaxFieldLength = 512; + private const int MaxStackSummaryLength = 1024; + private readonly ILogger _logger; public ClientErrorsController(ILogger logger) @@ -26,19 +32,69 @@ namespace JobTrackerApi.Controllers [HttpPost] public IActionResult Report([FromBody] ClientErrorReport report) { + var errorId = Normalize(report.ErrorId, 128) ?? "unknown"; + var at = Normalize(report.At, 128) ?? "unknown"; + var url = Normalize(report.Url, MaxFieldLength) ?? "unknown"; + var userAgent = Normalize(report.UserAgent, MaxFieldLength) ?? "unknown"; + var message = Normalize(report.Message, MaxFieldLength) ?? "unknown"; + + var stackHash = Hash(report.Stack); + var componentStackHash = Hash(report.ComponentStack); + var stackPreview = SummarizeStack(report.Stack); + var componentPreview = SummarizeStack(report.ComponentStack); + _logger.LogError( - "ClientError {ErrorId} at {At} url={Url} ua={UserAgent} msg={Message}\n{Stack}\n{ComponentStack}", - report.ErrorId ?? "unknown", - report.At ?? "unknown", - report.Url ?? "unknown", - report.UserAgent ?? "unknown", - report.Message ?? "unknown", - report.Stack ?? "", - report.ComponentStack ?? "" + "ClientError {ErrorId} at {At} url={Url} ua={UserAgent} msg={Message} stackHash={StackHash} componentHash={ComponentStackHash} stackPreview={StackPreview} componentPreview={ComponentPreview}", + errorId, + at, + url, + userAgent, + message, + stackHash, + componentStackHash, + stackPreview, + componentPreview ); return NoContent(); } + + internal static string? Normalize(string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) return null; + + var normalized = value.Trim().Replace("\r", " ").Replace("\n", " "); + if (normalized.Length <= maxLength) + { + return normalized; + } + + return normalized[..maxLength]; + } + + internal static string? SummarizeStack(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + + var lines = value + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(line => line.Replace("\r", string.Empty).Trim()) + .Where(line => line.Length > 0) + .Take(2) + .ToArray(); + + if (lines.Length == 0) return null; + + var summary = string.Join(" | ", lines); + return summary.Length <= MaxStackSummaryLength ? summary : summary[..MaxStackSummaryLength]; + } + + internal static string? Hash(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } } } - diff --git a/JobTrackerApi/Controllers/ExportController.cs b/JobTrackerApi/Controllers/ExportController.cs index 90f38e0..8af165c 100644 --- a/JobTrackerApi/Controllers/ExportController.cs +++ b/JobTrackerApi/Controllers/ExportController.cs @@ -1,4 +1,5 @@ using System.Text; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using JobTrackerApi.Data; @@ -7,6 +8,7 @@ namespace JobTrackerApi.Controllers { [ApiController] [Route("api/export")] + [Authorize(AuthenticationSchemes = "local")] public class ExportController : ControllerBase { private readonly JobTrackerContext _db; diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index 33e66a4..ecc1bd6 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -52,6 +52,7 @@ public sealed class ProfileCvController : ControllerBase ["certificates"] = "Certifications", ["languages"] = "Languages", ["interests"] = "Interests", + ["hobbies"] = "Interests", }; private const long MaxFileSizeBytes = 5 * 1024 * 1024; @@ -62,22 +63,34 @@ public sealed class ProfileCvController : ControllerBase private readonly UserManager _users; private readonly ISummarizerService _aiService; private readonly ICvAiClassifier _cvAiClassifier; + private readonly ICvAiNormalizer _cvAiNormalizer; private readonly JobTrackerContext _db; private readonly AppPaths _paths; private readonly ILogger _logger; private readonly ICvTemplateRenderer _cvTemplateRenderer; private readonly ICvPdfExporter _cvPdfExporter; + private readonly ICvProcessingQueue _cvProcessingQueue; + private readonly IAppEmailSender _emailSender; - public ProfileCvController(UserManager users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null) + public ProfileCvController(UserManager users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null) { _users = users; _aiService = aiService; _cvAiClassifier = cvAiClassifier ?? NoOpCvAiClassifier.Instance; + _cvAiNormalizer = cvAiNormalizer ?? NoOpCvAiNormalizer.Instance; _db = db; _paths = paths; _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; _cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer(); _cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter(); + _cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance; + _emailSender = emailSender ?? NoOpEmailSender.Instance; + } + + private sealed class NoOpEmailSender : IAppEmailSender + { + public static readonly NoOpEmailSender Instance = new(); + public Task SendAsync(string toEmail, string subject, string bodyText, CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class ThrowingCvPdfExporter : ICvPdfExporter @@ -246,26 +259,9 @@ public sealed class ProfileCvController : ControllerBase return BadRequest("The stored CV artifact could not be found for reprocessing."); } - await using var stream = System.IO.File.OpenRead(artifact.StoragePath); - var file = new FormFile(stream, 0, stream.Length, "file", artifact.OriginalFileName) - { - Headers = new HeaderDictionary(), - ContentType = artifact.MimeType - }; - - var extension = Path.GetExtension(artifact.OriginalFileName ?? string.Empty); - var result = await ExtractStructuredCvFromFileAsync(file, extension, HttpContext.RequestAborted); - await ApplyTextExtractionRunAsync(user, "reprocess", result.RawText, result.NormalizedText, result.StructuredCv, artifact.Id, HttpContext.RequestAborted); - - return Ok(new - { - reprocessed = true, - artifactId = artifact.Id, - extractionRunId = user.CurrentCvExtractionRunId, - profileVersion = user.CurrentCvProfileVersion, - structuredCv = result.StructuredCv, - sections = result.StructuredCv.Sections, - }); + var run = await CreateQueuedRunAsync(user.Id, artifact.Id, "reprocess", HttpContext.RequestAborted); + await _cvProcessingQueue.EnqueueAsync(run.Id, HttpContext.RequestAborted); + return Accepted(new { queued = true, extractionRunId = run.Id, status = run.Status }); } [HttpPost("rebuild")] @@ -275,22 +271,9 @@ public sealed class ProfileCvController : ControllerBase if (user is null) return Unauthorized(); if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before rebuilding it."); - var rebuilt = await _aiService.SummarizeSectionAsync( - "Rewrite this CV into a stronger master CV with clear sections such as Professional Summary, Core Skills, Experience Highlights, and Selected Achievements. Preserve only factual claims, avoid inventing employers or metrics, and make the output clean and ready for tailoring to job applications. Return only the rebuilt CV text.", - user.ProfileCvText, - 2200, - 700); - - if (string.IsNullOrWhiteSpace(rebuilt)) - { - return BadRequest("The AI service could not rebuild your CV text right now."); - } - - user.ProfileCvText = rebuilt.Trim(); - var structuredCv = await BuildStructuredCvAsync(user.ProfileCvText, HttpContext.RequestAborted); - await ApplyTextExtractionRunAsync(user, "rebuild", user.ProfileCvText, user.ProfileCvText, structuredCv, user.CurrentCvUploadArtifactId, HttpContext.RequestAborted); - - return Ok(new { rebuilt = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText, structuredCv, sections = structuredCv.Sections, extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion }); + var run = await CreateQueuedRunAsync(user.Id, user.CurrentCvUploadArtifactId, "rebuild", HttpContext.RequestAborted); + await _cvProcessingQueue.EnqueueAsync(run.Id, HttpContext.RequestAborted); + return Accepted(new { queued = true, extractionRunId = run.Id, status = run.Status }); } [HttpPost("rewrite-section")] @@ -446,14 +429,15 @@ public sealed class ProfileCvController : ControllerBase var source = string.IsNullOrWhiteSpace(request?.Text) ? user.ProfileCvText : request!.Text; if (string.IsNullOrWhiteSpace(source)) return BadRequest("Add or import CV text before parsing sections."); - var structuredCv = await BuildStructuredCvAsync(source, HttpContext.RequestAborted); + var normalizedSource = await MaybeReconstructStructuredCvAsync(source, HttpContext.RequestAborted); + var structuredCv = await BuildStructuredCvAsync(normalizedSource, HttpContext.RequestAborted); if (string.IsNullOrWhiteSpace(request?.Text)) { - user.ProfileCvText = source; + user.ProfileCvText = normalizedSource; } - await ApplyTextExtractionRunAsync(user, "parse", source, source, structuredCv, user.CurrentCvUploadArtifactId, HttpContext.RequestAborted); + await ApplyTextExtractionRunAsync(user, "parse", source, normalizedSource, structuredCv, user.CurrentCvUploadArtifactId, HttpContext.RequestAborted); - return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(source), extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion }); + return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(normalizedSource), extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion }); } [HttpPost("improve")] @@ -463,22 +447,9 @@ public sealed class ProfileCvController : ControllerBase if (user is null) return Unauthorized(); if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before improving it."); - var improved = await _aiService.SummarizeSectionAsync( - "Rewrite this CV into a cleaner, better-structured master CV profile. Preserve factual claims, employers, skills, and measurable results. Improve clarity, tighten wording, use strong bullet-style phrasing, and keep it ready for further tailoring to specific roles. Return only the improved CV text.", - user.ProfileCvText, - 1800, - 500); - - if (string.IsNullOrWhiteSpace(improved)) - { - return BadRequest("The AI service could not improve your CV text right now."); - } - - user.ProfileCvText = improved.Trim(); - var structuredCv = await BuildStructuredCvAsync(user.ProfileCvText, HttpContext.RequestAborted); - await ApplyTextExtractionRunAsync(user, "improve", user.ProfileCvText, user.ProfileCvText, structuredCv, user.CurrentCvUploadArtifactId, HttpContext.RequestAborted); - - return Ok(new { improved = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText, structuredCv, sections = structuredCv.Sections, extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion }); + var run = await CreateQueuedRunAsync(user.Id, user.CurrentCvUploadArtifactId, "improve", HttpContext.RequestAborted); + await _cvProcessingQueue.EnqueueAsync(run.Id, HttpContext.RequestAborted); + return Accepted(new { queued = true, extractionRunId = run.Id, status = run.Status }); } private static string BuildRewriteSourceText(string? sectionName, string? sourceText, StructuredCvProfile structuredCv) @@ -671,6 +642,13 @@ public sealed class ProfileCvController : ControllerBase private async Task BuildStructuredCvAsync(string text, CancellationToken cancellationToken) { + if (LooksLikeNormalizedMarkdownCv(text)) + { + var normalized = BuildStructuredCvFromNormalizedMarkdown(text); + AnnotateStructuredCv(normalized, "normalized-markdown", 0.78); + return StructuredCvProfileJson.Normalize(normalized); + } + var parseSource = NormalizeTextForStructuredParsing(text); var parsedSections = ParseSections(parseSource) .Select(section => new StructuredCvSection @@ -711,6 +689,40 @@ public sealed class ProfileCvController : ControllerBase var extracted = await TryExtractStructuredCvAsync(parseSource, cancellationToken); var merged = StructuredCvProfileJson.Merge(extracted, fallback); merged.Contact.FullName ??= GuessFullName(text) ?? GuessFullNameFromEmail(merged.Contact.Email); + + if (!IsPlausibleLocationValue(merged.Contact.Location, merged.Contact.FullName)) + { + merged.Contact.Location = PreferDetectedLocation(text, null, merged.Contact.FullName); + } + + merged.Jobs = merged.Jobs + .Where(job => !LooksLikePersonName(job.Title ?? string.Empty)) + .ToList(); + + var reparsedJobs = ParseJobsHeuristically(text) + .Where(job => !LooksLikePersonName(job.Title ?? string.Empty)) + .ToList(); + var existingFirstTitle = merged.Jobs.FirstOrDefault()?.Title; + var reparsedFirstTitle = reparsedJobs.FirstOrDefault()?.Title; + + if (LooksLikePersonName(existingFirstTitle ?? string.Empty) + && LooksLikeRoleOrHeadline(reparsedFirstTitle ?? string.Empty) + && ArePlausibleJobs(reparsedJobs, merged.Contact.FullName)) + { + merged.Jobs = reparsedJobs; + } + else if (ArePlausibleJobs(merged.Jobs, merged.Contact.FullName)) + { + if (ScoreJobs(reparsedJobs, merged.Contact.FullName) > ScoreJobs(merged.Jobs, merged.Contact.FullName)) + { + merged.Jobs = reparsedJobs; + } + } + else if (ArePlausibleJobs(reparsedJobs, merged.Contact.FullName)) + { + merged.Jobs = reparsedJobs; + } + return StructuredCvProfileJson.Normalize(merged); } @@ -829,6 +841,167 @@ public sealed class ProfileCvController : ControllerBase await _db.SaveChangesAsync(cancellationToken); } + private async Task CreateQueuedRunAsync(string ownerUserId, int? artifactId, string trigger, CancellationToken cancellationToken) + { + var run = new CvExtractionRun + { + OwnerUserId = ownerUserId, + ArtifactId = artifactId, + Trigger = trigger, + ParserVersion = ParserVersion, + NormalizerVersion = NormalizerVersion, + LlmPromptVersion = LlmPromptVersion, + Status = "queued", + StartedAtUtc = DateTimeOffset.UtcNow, + }; + _db.CvExtractionRuns.Add(run); + await _db.SaveChangesAsync(cancellationToken); + return run; + } + + public async Task ProcessQueuedRunAsync(int runId, CancellationToken cancellationToken) + { + var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == runId, cancellationToken); + if (run is null) return; + var user = await _users.FindByIdAsync(run.OwnerUserId); + if (user is null) + { + run.Status = "failed"; + run.ErrorMessage = "CV processing user was not found."; + run.CompletedAtUtc = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(cancellationToken); + return; + } + + run.Status = "running"; + run.ErrorMessage = null; + await _db.SaveChangesAsync(cancellationToken); + + try + { + switch (run.Trigger) + { + case "rebuild": + { + if (string.IsNullOrWhiteSpace(user.ProfileCvText)) throw new InvalidOperationException("Add or import CV text before rebuilding it."); + var rebuilt = await _aiService.SummarizeSectionAsync( + "Rewrite this CV into a stronger master CV with clear sections such as Professional Summary, Core Skills, Experience Highlights, and Selected Achievements. Preserve only factual claims, avoid inventing employers or metrics, and make the output clean and ready for tailoring to job applications. Return only the rebuilt CV text.", + user.ProfileCvText, + 2200, + 700); + if (string.IsNullOrWhiteSpace(rebuilt)) throw new InvalidOperationException("The AI service could not rebuild your CV text right now."); + + var normalizedText = rebuilt.Trim(); + var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken); + await ApplyQueuedRunResultAsync(run, user, normalizedText, normalizedText, structuredCv, run.ArtifactId, cancellationToken); + break; + } + case "improve": + { + if (string.IsNullOrWhiteSpace(user.ProfileCvText)) throw new InvalidOperationException("Add or import CV text before improving it."); + var improved = await _aiService.SummarizeSectionAsync( + "Rewrite this CV into a cleaner, better-structured master CV profile. Preserve factual claims, employers, skills, and measurable results. Improve clarity, tighten wording, use strong bullet-style phrasing, and keep it ready for further tailoring to specific roles. Return only the improved CV text.", + user.ProfileCvText, + 1800, + 500); + if (string.IsNullOrWhiteSpace(improved)) throw new InvalidOperationException("The AI service could not improve your CV text right now."); + + var normalizedText = improved.Trim(); + var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken); + await ApplyQueuedRunResultAsync(run, user, normalizedText, normalizedText, structuredCv, run.ArtifactId, cancellationToken); + break; + } + case "reprocess": + { + var artifact = await _db.CvUploadArtifacts.FirstOrDefaultAsync(x => x.Id == run.ArtifactId && x.OwnerUserId == user.Id, cancellationToken); + if (artifact is null) throw new InvalidOperationException("Upload a CV before reprocessing it."); + if (string.IsNullOrWhiteSpace(artifact.StoragePath) || !System.IO.File.Exists(artifact.StoragePath)) + { + throw new InvalidOperationException("The stored CV artifact could not be found for reprocessing."); + } + + await using var stream = System.IO.File.OpenRead(artifact.StoragePath); + var file = new FormFile(stream, 0, stream.Length, "file", artifact.OriginalFileName) + { + Headers = new HeaderDictionary(), + ContentType = artifact.MimeType + }; + var extension = Path.GetExtension(artifact.OriginalFileName ?? string.Empty); + var result = await ExtractStructuredCvFromFileAsync(file, extension, cancellationToken); + await ApplyQueuedRunResultAsync(run, user, result.RawText, result.NormalizedText, result.StructuredCv, artifact.Id, cancellationToken); + break; + } + default: + throw new InvalidOperationException($"Unsupported CV processing trigger '{run.Trigger}'."); + } + + await SendRunCompletionEmailAsync(user, run, true, cancellationToken); + } + catch (Exception ex) + { + run.Status = "failed"; + run.ErrorMessage = ex.Message; + run.CompletedAtUtc = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(cancellationToken); + await SendRunCompletionEmailAsync(user, run, false, cancellationToken); + _logger.LogWarning(ex, "CV processing run {RunId} failed for user {UserId}", run.Id, user.Id); + } + } + + private async Task ApplyQueuedRunResultAsync(CvExtractionRun run, ApplicationUser user, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, CancellationToken cancellationToken) + { + structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1; + structuredCv.Metadata.AppliedExtractionRunId = run.Id; + structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow; + var structuredJson = StructuredCvProfileJson.Serialize(structuredCv); + + run.RawExtractedText = rawText; + run.NormalizedText = normalizedText; + run.StructuredProfileJson = structuredJson; + run.Status = "applied"; + run.CompletedAtUtc = DateTimeOffset.UtcNow; + run.AppliedAtUtc = run.CompletedAtUtc; + + user.ProfileCvText = normalizedText; + user.ProfileCvStructureJson = structuredJson; + user.CurrentCvExtractionRunId = run.Id; + user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion; + if (artifactId.HasValue) + { + user.CurrentCvUploadArtifactId = artifactId.Value; + } + + var update = await _users.UpdateAsync(user); + if (!update.Succeeded) + { + run.Status = "failed"; + run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description)); + await _db.SaveChangesAsync(cancellationToken); + throw new InvalidOperationException(run.ErrorMessage); + } + + await _db.SaveChangesAsync(cancellationToken); + } + + private async Task SendRunCompletionEmailAsync(ApplicationUser user, CvExtractionRun run, bool success, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(user.Email)) return; + + var subject = success ? $"Your CV {run.Trigger} is complete" : $"Your CV {run.Trigger} failed"; + var body = success + ? $"Your CV {run.Trigger} request finished successfully.\n\nRun ID: {run.Id}\nStatus: {run.Status}\nCompleted: {run.CompletedAtUtc:O}\n" + : $"Your CV {run.Trigger} request failed.\n\nRun ID: {run.Id}\nStatus: {run.Status}\nError: {run.ErrorMessage}\nCompleted: {run.CompletedAtUtc:O}\n"; + + try + { + await _emailSender.SendAsync(user.Email, subject, body, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "CV processing completion email failed for run {RunId} user {UserId}", run.Id, user.Id); + } + } + private static void AnnotateStructuredCv(StructuredCvProfile profile, string method, double confidence) { var now = DateTimeOffset.UtcNow; @@ -914,8 +1087,16 @@ public sealed class ProfileCvController : ControllerBase foreach (var line in normalized.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Take(6)) { var cleaned = line.Trim().TrimStart('#').Trim(); + cleaned = Regex.Replace(cleaned, @"(?<=[a-z])(?=[A-Z])", " "); if (cleaned.Length < 4 || cleaned.Length > 80) continue; if (cleaned.Contains('@') || Regex.IsMatch(cleaned, @"\d")) continue; + + var nameMatch = Regex.Match(cleaned, @"^(?[A-Z][A-Za-z'`.-]+(?:\s+[A-Z][A-Za-z'`.-]+){1,3})(?:\s+(?:Real Estate Agent|Store Manager|Web Developer|Developer|Engineer|Consultant|Specialist|Analyst).*)?$", RegexOptions.IgnoreCase); + if (nameMatch.Success) + { + return nameMatch.Groups["name"].Value.Trim(); + } + if (!Regex.IsMatch(cleaned, @"^[A-Z][A-Za-z'`.-]+(?:\s+[A-Z][A-Za-z'`.-]+){1,4}$")) continue; return cleaned; } @@ -981,8 +1162,8 @@ public sealed class ProfileCvController : ControllerBase var normalized = parseSource.Replace("\r\n", "\n").Trim(); profile.Contact.Email = NullIfWhitespace(Regex.Match(rawSource, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", RegexOptions.IgnoreCase).Value); - profile.Contact.Phone = NullIfWhitespace(Regex.Match(rawSource, @"(? + !line.Contains('@') + && !Regex.IsMatch(line, @"https?://|www\.", RegexOptions.IgnoreCase) + && Regex.IsMatch(line, @"^[A-Z][A-Za-z.' -]+(?:,\s*[A-Z][A-Za-z.' -]+)?$") + && !line.Contains("Skills", StringComparison.OrdinalIgnoreCase) + && !line.Contains("Summary", StringComparison.OrdinalIgnoreCase) + && !line.Contains("Developer", StringComparison.OrdinalIgnoreCase) + && !line.Contains("Agent", StringComparison.OrdinalIgnoreCase) + && !string.Equals(line, profile.Contact.FullName, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(profile.Contact.Location)) + { + profile.Contact.Location = Regex.Replace(profile.Contact.Location, @"\bSkills\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim(' ', ','); } var summarySection = sections.FirstOrDefault(section => section.Name == "Professional Summary"); @@ -1006,11 +1206,15 @@ public sealed class ProfileCvController : ControllerBase RegexOptions.IgnoreCase | RegexOptions.Singleline); if (flattenedSummary.Success) { - profile.Summary = SplitSentences(flattenedSummary.Groups["body"].Value, 5); + profile.Summary = SplitSentences(flattenedSummary.Groups["body"].Value, 5) + .Where(item => !Regex.IsMatch(item, @"^:?\s*https?://", RegexOptions.IgnoreCase)) + .ToList(); } else if (!string.IsNullOrWhiteSpace(summarySection.Content)) { - profile.Summary = SplitSentences(summarySection.Content, 5); + profile.Summary = SplitSentences(summarySection.Content, 5) + .Where(item => !Regex.IsMatch(item, @"^:?\s*https?://", RegexOptions.IgnoreCase)) + .ToList(); } var interestsSection = sections.FirstOrDefault(section => section.Name == "Interests"); @@ -1041,9 +1245,9 @@ public sealed class ProfileCvController : ControllerBase } var skills = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (Match match in Regex.Matches(rawSource, @"(? section.Name == "General")) { @@ -1092,17 +1300,219 @@ public sealed class ProfileCvController : ControllerBase .ToList(); } + private static readonly string[] ConservativeSkillHints = + { + "C#", ".NET", "ASP.NET", "SQL", "JavaScript", "TypeScript", "Python", "Ruby on Rails", "Ruby", "React", "Azure", "Azure DevOps", "GitHub", "CI/CD", "HTML5", "CSS", "MySQL", "PHP OOP", "Project management", "Revenue generation", "Business development", "Effective marketing", "Organisational capacity", "Operability and commitment", "Attention to Detail", "Property Valuation", "Retail Market Analysis", "Client Relationship Management", "Digital Marketing" + }; + private static List SplitListLike(string content) { return content .Replace("\r\n", "\n") - .Split(new[] { '\n', ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Split(new[] { '\n', ',', ';', '•', '●' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .SelectMany(item => item.Contains(" ", StringComparison.Ordinal) ? Regex.Split(item, @"\s{2,}") : new[] { item }) .Select(item => item.Trim().TrimStart('-', '•', '*', ' ')) .Where(item => item.Length > 1) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } + private static IEnumerable ExtractConservativeSkills(string content) + { + foreach (var skill in ConservativeSkillHints) + { + if (Regex.IsMatch(content, $@"(? ExtractSkillsFromBullets(IEnumerable bullets) + { + return ExtractConservativeSkills(string.Join("\n", bullets)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static IEnumerable ExtractSkillsHeuristically(string content) + { + var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var skill in ExtractConservativeSkills(content)) + { + if (yielded.Add(skill)) yield return skill; + } + + var highlightsMatch = Regex.Match(content, @"(?:Highlights|Core Skills|Skills|Technical Skills|Skill Highlights|Competencies)\s*(?.*?)(?=(?:Experience|Education|Languages|Interests|Projects|Certifications|$))", RegexOptions.IgnoreCase | RegexOptions.Singleline); + if (highlightsMatch.Success) + { + foreach (var item in SplitListLike(highlightsMatch.Groups["body"].Value)) + { + var trimmed = item.Trim(); + if (trimmed.Length >= 3 && trimmed.Length <= 80 && trimmed.Count(char.IsLetter) >= 3) + { + if (yielded.Add(trimmed)) yield return trimmed; + } + } + } + } + + private static string? NormalizeDetectedPhone(string? value) + { + var trimmed = NullIfWhitespace(value); + if (trimmed is null) return null; + + var digits = trimmed.Count(char.IsDigit); + if (digits < 7) return null; + + var looksLikeRawCoordinates = trimmed.Contains(" -") && digits > 18 && !trimmed.Contains('+') && !trimmed.Contains('('); + if (looksLikeRawCoordinates) return null; + + return trimmed; + } + + private static string? NormalizeDetectedWebsite(string? value, string? email) + { + var trimmed = NullIfWhitespace(value); + if (trimmed is null) return null; + if (!trimmed.Contains('.', StringComparison.Ordinal)) return null; + if (trimmed.Contains('@')) return null; + if (trimmed.Equals("gmail.com", StringComparison.OrdinalIgnoreCase)) return null; + + var candidate = trimmed.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? trimmed : $"https://{trimmed}"; + if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) return null; + if (string.IsNullOrWhiteSpace(uri.Host) || !uri.Host.Contains('.', StringComparison.Ordinal)) return null; + + return trimmed.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? trimmed : uri.Host; + } + + private static string? ExtractPreferredWebsite(string rawSource, string? email) + { + foreach (Match match in Regex.Matches(rawSource, @"\b(?:https?://)?(?:www\.)?[A-Z0-9.-]+\.[A-Z]{2,}(?:/[A-Z0-9._~:/?#\[\]@!$&'()*+,;=-]*)?", RegexOptions.IgnoreCase)) + { + var candidate = NormalizeDetectedWebsite(match.Value, email); + if (candidate is null) continue; + if (candidate.Contains("linkedin.com", StringComparison.OrdinalIgnoreCase)) continue; + return candidate; + } + + return null; + } + + private static string? PreferDetectedLocation(string source, string? fallback, string? fullName = null) + { + var normalizedFallback = NullIfWhitespace(fallback); + if (normalizedFallback is not null) + { + normalizedFallback = Regex.Replace(normalizedFallback, @",?\s*(Hobbies|Education)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim(' ', ','); + } + + if (IsPlausibleLocationValue(normalizedFallback, fullName)) + { + return normalizedFallback; + } + + var lines = source.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var rawLine in lines.Take(10)) + { + var line = Regex.Replace(rawLine, @",?\s*(Hobbies|Education)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim(' ', ','); + if (!IsPlausibleLocationValue(line, fullName)) continue; + return line; + } + + return IsPlausibleLocationValue(normalizedFallback, fullName) ? normalizedFallback : null; + } + + private static bool IsPlausibleLocationValue(string? value, string? fullName) + { + var candidate = NullIfWhitespace(value); + if (candidate is null) return false; + if (LooksLikeRoleOrHeadline(candidate)) return false; + if (!string.IsNullOrWhiteSpace(fullName)) + { + if (candidate.Equals(fullName, StringComparison.OrdinalIgnoreCase)) return false; + if (candidate.StartsWith(fullName + " ", StringComparison.OrdinalIgnoreCase)) return false; + } + + if (candidate.Contains("Education", StringComparison.OrdinalIgnoreCase) + || candidate.Contains("Hobbies", StringComparison.OrdinalIgnoreCase) + || candidate.Contains("Skills", StringComparison.OrdinalIgnoreCase) + || candidate.Contains("Summary", StringComparison.OrdinalIgnoreCase)) return false; + if (candidate.Contains('@') || Regex.IsMatch(candidate, @"https?://|www\.", RegexOptions.IgnoreCase)) return false; + if (candidate.Count(char.IsDigit) >= 5) return false; + if (Regex.IsMatch(candidate, @"^\d+\s+.+")) return true; + + var normalized = Regex.Replace(candidate, @"\s+", " ").Trim(' ', ','); + if (normalized.Length > 80) return false; + + if (Regex.IsMatch(normalized, @"^[A-Z][A-Za-z.' -]+,\s*[A-Z][A-Za-z.' -]+(?:,\s*[A-Z][A-Za-z.' -]+)?$")) return true; + if (Regex.IsMatch(normalized, @"^[A-Z][A-Za-z.' -]+(?:\s+[A-Z][A-Za-z.' -]+){0,2}$") && !LooksLikeRoleOrHeadline(normalized)) return true; + + return false; + } + + private static bool LooksLikeRoleOrHeadline(string value) + { + return Regex.IsMatch(value, @"\b(real estate agent|developer|engineer|manager|consultant|specialist|analyst|designer|technician|administrator|architect|director|coordinator|assistant|lead|owner|founder|recruiter|teacher|writer|producer|officer|supervisor|sales)\b", RegexOptions.IgnoreCase); + } + + private static bool LooksLikePersonName(string value) + { + return Regex.IsMatch(value, @"^[A-Z][A-Za-z'`.-]+(?:\s+[A-Z][A-Za-z'`.-]+){1,3}$") + && !LooksLikeRoleOrHeadline(value); + } + + private static bool ArePlausibleJobs(List? jobs, string? fullName) + { + if (jobs is null || jobs.Count == 0) return false; + return jobs.Any(job => IsPlausibleJob(job, fullName)); + } + + private static int ScoreJobs(List? jobs, string? fullName) + { + if (jobs is null || jobs.Count == 0) return 0; + var first = jobs[0]; + var score = 0; + if (IsPlausibleJob(first, fullName)) score += 5; + if (!string.IsNullOrWhiteSpace(first.Title) && LooksLikeRoleOrHeadline(first.Title)) score += 4; + if (!string.IsNullOrWhiteSpace(first.Company)) score += 2; + if (!string.IsNullOrWhiteSpace(first.Start) || !string.IsNullOrWhiteSpace(first.End)) score += 2; + if (first.Bullets.Count > 0) score += 2; + score += Math.Min(jobs.Count, 3); + return score; + } + + private static bool IsPlausibleJob(StructuredCvJob? job, string? fullName) + { + if (job is null) return false; + var title = NullIfWhitespace(job.Title); + var company = NullIfWhitespace(job.Company); + var location = NullIfWhitespace(job.Location); + var hasEvidence = !string.IsNullOrWhiteSpace(company) + || !string.IsNullOrWhiteSpace(location) + || !string.IsNullOrWhiteSpace(job.Start) + || !string.IsNullOrWhiteSpace(job.End) + || job.Bullets.Count > 0; + + if (title is null) return hasEvidence; + if (!string.IsNullOrWhiteSpace(fullName) && title.Equals(fullName, StringComparison.OrdinalIgnoreCase)) return false; + if (LooksLikePersonName(title)) return false; + if (title.Contains('@') || Regex.IsMatch(title, @"https?://|www\.", RegexOptions.IgnoreCase)) return false; + if (Regex.IsMatch(title, @"^(?:\d{2}/\d{4}|\d{4})\s*(?:[-–]|to)\s*(?:\d{2}/\d{4}|\d{4}|Present|Current)$", RegexOptions.IgnoreCase)) return false; + if (!hasEvidence && !LooksLikeRoleOrHeadline(title)) return false; + return true; + } + + private static string? CleanHeadline(string? value, string? fullName) + { + var trimmed = NullIfWhitespace(value); + if (trimmed is null) return null; + if (!string.IsNullOrWhiteSpace(fullName) && trimmed.Equals(fullName, StringComparison.OrdinalIgnoreCase)) return null; + if (trimmed.Contains('@') || trimmed.Count(char.IsDigit) > 3) return null; + return trimmed; + } + private static List ParseLanguagesHeuristically(string content) { var languages = new List(); @@ -1129,7 +1539,8 @@ public sealed class ProfileCvController : ControllerBase private static List ParseEducationHeuristically(string content) { - var blocks = Regex.Split(content, @"\n\s*\n") + var normalized = content.Replace("\r\n", "\n").Trim(); + var blocks = Regex.Split(normalized, @"\n\s*\n|(?=###\s+)|(?=(?:Bachelor|Master|Doctor|Associate|Diploma|Certificate|BSc|BA|MSc|MA|PhD)\b)", RegexOptions.IgnoreCase) .Select(block => block.Trim()) .Where(block => block.Length > 0) .ToList(); @@ -1137,6 +1548,13 @@ public sealed class ProfileCvController : ControllerBase var items = new List(); foreach (var block in blocks) { + var candidate = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Education", Content = block } }).Education; + if (candidate.Count > 0) + { + items.AddRange(candidate); + continue; + } + var lines = block.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); if (lines.Count == 0) continue; @@ -1162,7 +1580,88 @@ public sealed class ProfileCvController : ControllerBase private static List ParseJobsHeuristically(string content) { - var normalized = content.Replace("\r\n", "\n"); + var normalized = content.Replace("\r\n", "\n").Trim(); + var structured = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Work Experience", Content = normalized } }).Jobs; + if (ArePlausibleJobs(structured, null)) + { + return structured; + } + + var simpleLines = normalized.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var inlineDateIndex = Array.FindIndex(simpleLines, line => Regex.IsMatch(line, @".+\d{2}/\d{4}\s+to\s+\d{2}/\d{4}", RegexOptions.IgnoreCase) || Regex.IsMatch(line, @".+\d{4}\s*(?:[-–]|to)\s*(?:\d{4}|Present|Current)", RegexOptions.IgnoreCase)); + if (inlineDateIndex >= 0) + { + var titleLine = Regex.Replace(simpleLines[inlineDateIndex], @"\s*[-–]?\s*\d{2}/\d{4}\s+to\s+\d{2}/\d{4}.*$", string.Empty, RegexOptions.IgnoreCase); + titleLine = Regex.Replace(titleLine, @"\s*[-–]?\s*\d{4}\s*[-–]\s*(?:\d{4}|Present|Current).*$", string.Empty, RegexOptions.IgnoreCase).Trim(); + var companyOrLocation = inlineDateIndex + 1 < simpleLines.Length ? simpleLines[inlineDateIndex + 1] : null; + var datesMatch = Regex.Match(simpleLines[inlineDateIndex], @"(\d{2}/\d{4}|\d{4})\s*(?:to|[-–])\s*(\d{2}/\d{4}|\d{4}|Present|Current)", RegexOptions.IgnoreCase); + var bullets = simpleLines.Skip(inlineDateIndex + 2).Where(line => line.Length > 12).ToList(); + if (!string.IsNullOrWhiteSpace(titleLine)) + { + return new List + { + new StructuredCvJob + { + Title = titleLine, + Company = companyOrLocation, + Start = datesMatch.Success ? datesMatch.Groups[1].Value : null, + End = datesMatch.Success ? datesMatch.Groups[2].Value : null, + IsCurrent = datesMatch.Success && (string.Equals(datesMatch.Groups[2].Value, "Present", StringComparison.OrdinalIgnoreCase) || string.Equals(datesMatch.Groups[2].Value, "Current", StringComparison.OrdinalIgnoreCase)), + Bullets = bullets, + Skills = ExtractSkillsFromBullets(bullets), + } + }; + } + } + + var dateIndex = Array.FindIndex(simpleLines, line => Regex.IsMatch(line, @"(?:\d{2}/\d{4}|\d{4})\s*(?:[-–]|to)\s*(?:\d{2}/\d{4}|\d{4}|Present|Current)", RegexOptions.IgnoreCase)); + if (dateIndex >= 0) + { + if (dateIndex + 2 < simpleLines.Length && LooksLikeRoleOrHeadline(simpleLines[dateIndex + 1])) + { + var datesLine = simpleLines[dateIndex]; + var titleLine = simpleLines[dateIndex + 1]; + var companyLine = simpleLines[dateIndex + 2]; + var bullets = SplitSentences(string.Join(" ", simpleLines.Skip(dateIndex + 3)), 6); + var parts = Regex.Split(datesLine, @"\s*[-–]\s*"); + return new List + { + new StructuredCvJob + { + Title = titleLine, + Company = companyLine, + Start = parts.FirstOrDefault(), + End = parts.Skip(1).FirstOrDefault(), + IsCurrent = string.Equals(parts.Skip(1).FirstOrDefault(), "Present", StringComparison.OrdinalIgnoreCase) || string.Equals(parts.Skip(1).FirstOrDefault(), "Current", StringComparison.OrdinalIgnoreCase), + Bullets = bullets, + Skills = ExtractSkillsFromBullets(bullets), + } + }; + } + + if (dateIndex >= 2) + { + var titleLine = simpleLines[dateIndex - 2]; + var locationLine = simpleLines[dateIndex - 1]; + var datesLine = simpleLines[dateIndex]; + var bullets = simpleLines.Skip(dateIndex + 1).Where(line => line.Length > 12).ToList(); + var parts = Regex.Split(datesLine, @"\s*[-–]\s*"); + return new List + { + new StructuredCvJob + { + Title = titleLine, + Location = locationLine, + Start = parts.FirstOrDefault(), + End = parts.Skip(1).FirstOrDefault(), + IsCurrent = string.Equals(parts.Skip(1).FirstOrDefault(), "Present", StringComparison.OrdinalIgnoreCase) || string.Equals(parts.Skip(1).FirstOrDefault(), "Current", StringComparison.OrdinalIgnoreCase), + Bullets = bullets, + Skills = ExtractSkillsFromBullets(bullets), + } + }; + } + } + var pattern = new Regex(@"(?[A-Z][A-Z\s/&-]{3,})\s*\n(?<dates>\d{4}\s*[-–]\s*(?:\d{4}|Present|Current))(?<body>.*?)(?=(?:\n[A-Z][A-Z\s/&-]{3,}\s*\n\d{4}\s*[-–]\s*(?:\d{4}|Present|Current))|\z)", RegexOptions.Singleline); var jobs = new List<StructuredCvJob>(); @@ -1181,7 +1680,7 @@ public sealed class ProfileCvController : ControllerBase End = NullIfWhitespace(dates.Skip(1).FirstOrDefault()), IsCurrent = string.Equals(dates.Skip(1).FirstOrDefault(), "present", StringComparison.OrdinalIgnoreCase) || string.Equals(dates.Skip(1).FirstOrDefault(), "current", StringComparison.OrdinalIgnoreCase), Bullets = bullets, - Skills = bullets.SelectMany(SplitListLike).Where(item => Regex.IsMatch(item, @"^(?:C#|\.NET|ASP\.NET|SQL|JavaScript|TypeScript|Python|Ruby on Rails|Ruby|React|Azure DevOps|GitHub|CI/CD)$", RegexOptions.IgnoreCase)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(), + Skills = ExtractSkillsFromBullets(bullets), }); } @@ -1545,7 +2044,20 @@ public sealed class ProfileCvController : ControllerBase private async Task<string> MaybeReconstructStructuredCvAsync(string text, CancellationToken cancellationToken) { var normalized = text.Trim(); - if (!LooksLikeFlattenedCvExtraction(normalized)) + var forceAiNormalizer = string.Equals(Environment.GetEnvironmentVariable("CV_FORCE_AI_NORMALIZER"), "true", StringComparison.OrdinalIgnoreCase); + if (forceAiNormalizer) + { + var forced = await _cvAiNormalizer.NormalizeAsync(normalized, cancellationToken); + if (!string.IsNullOrWhiteSpace(forced?.NormalizedText)) + { + return forced.NormalizedText.Trim(); + } + } + + var looksFlattened = LooksLikeFlattenedCvExtraction(normalized); + var hasRecoverableSignals = HasRecoverableSectionSignals(normalized); + + if (!looksFlattened && hasRecoverableSignals) { return normalized; } @@ -1556,7 +2068,17 @@ public sealed class ProfileCvController : ControllerBase 2800, 900); - return string.IsNullOrWhiteSpace(reconstructed) ? normalized : reconstructed.Trim(); + var candidate = string.IsNullOrWhiteSpace(reconstructed) ? normalized : reconstructed.Trim(); + if (LooksLikeFlattenedCvExtraction(candidate) || !HasRecoverableSectionSignals(candidate)) + { + var aiNormalized = await _cvAiNormalizer.NormalizeAsync(normalized, cancellationToken); + if (!string.IsNullOrWhiteSpace(aiNormalized?.NormalizedText)) + { + return aiNormalized.NormalizedText.Trim(); + } + } + + return candidate; } private static bool LooksLikeFlattenedCvExtraction(string text) @@ -1575,6 +2097,74 @@ public sealed class ProfileCvController : ControllerBase || (normalized.Contains(" + ") && bulletCount > 0 && lineCount <= 10); } + private static bool LooksLikeNormalizedMarkdownCv(string text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + return Regex.IsMatch(text, @"(?im)^#\s+(Contact|Professional Summary|Work Experience|Education|Skills|Languages|Interests)\s*$"); + } + + private static StructuredCvProfile BuildStructuredCvFromNormalizedMarkdown(string text) + { + var sections = ParseSections(text) + .Select(section => new StructuredCvSection + { + Name = section.Name, + Content = section.Content, + WordCount = CountWords(section.Content), + }) + .ToList(); + + var profile = StructuredCvProfileJson.FromSections(sections); + profile.Sections = sections; + + if (string.IsNullOrWhiteSpace(profile.Contact.FullName)) + { + profile.Contact.FullName = GuessFullName(text) ?? GuessFullNameFromEmail(profile.Contact.Email); + } + + var contactSection = sections.FirstOrDefault(section => section.Name == "Contact"); + profile.Contact.Location = PreferDetectedLocation(contactSection.Content ?? text, profile.Contact.Location, profile.Contact.FullName); + profile.Summary = CondenseSummary(profile.Summary); + profile.Skills = OrderSkills(profile.Skills); + profile.Interests = CleanInterestItems(profile.Interests); + + foreach (var job in profile.Jobs) + { + job.Bullets = job.Bullets.Where(bullet => !bullet.Contains("Detail not specified", StringComparison.OrdinalIgnoreCase)).ToList(); + } + + foreach (var education in profile.Education) + { + education.Details = education.Details.Where(detail => !detail.Contains("Detail not specified", StringComparison.OrdinalIgnoreCase)).ToList(); + } + + return profile; + } + + private static List<string> CondenseSummary(List<string> summary) + { + if (summary.Count <= 1) return summary; + var joined = string.Join(" ", summary).Trim(); + return string.IsNullOrWhiteSpace(joined) ? new List<string>() : new List<string> { joined }; + } + + private static List<string> OrderSkills(List<string> skills) + { + return skills + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(skill => skill, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static List<string> CleanInterestItems(List<string> interests) + { + return interests + .Where(item => !item.Contains("linkedin", StringComparison.OrdinalIgnoreCase) + && !item.Contains("realtor", StringComparison.OrdinalIgnoreCase) + && !Regex.IsMatch(item, @"https?://|www\.", RegexOptions.IgnoreCase)) + .ToList(); + } + private static string? CanonicalizeSectionHeading(string line) { if (string.IsNullOrWhiteSpace(line)) return null; @@ -1592,6 +2182,14 @@ public sealed class ProfileCvController : ControllerBase return SectionAliases.TryGetValue(normalized, out var canonical) ? canonical : null; } + private static bool HasRecoverableSectionSignals(string text) + { + var sections = ParseSections(text); + return sections.Any(section => !string.Equals(section.Name, "General", StringComparison.OrdinalIgnoreCase)) + || Regex.IsMatch(text, @"(?im)^\s*(Contact|Professional Summary|Summary|Work Experience|Experience|Education|Skills|Languages|Interests)\s*:?") + || Regex.IsMatch(text, @"(?im)^\s*#\s*(Contact|Professional Summary|Summary|Work Experience|Experience|Education|Skills|Languages|Interests)"); + } + private static async Task<string> ExtractTextAsync(IFormFile file, string extension) { if (string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)) diff --git a/JobTrackerApi/Controllers/UsersController.cs b/JobTrackerApi/Controllers/UsersController.cs index a041e4e..ea8f2b1 100644 --- a/JobTrackerApi/Controllers/UsersController.cs +++ b/JobTrackerApi/Controllers/UsersController.cs @@ -3,6 +3,7 @@ using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using System.Security.Claims; @@ -136,6 +137,7 @@ public sealed class UsersController : ControllerBase } [HttpPost("{id}/send-password-reset")] + [EnableRateLimiting("auth-email")] public async Task<IActionResult> SendPasswordReset([FromRoute] string id, CancellationToken cancellationToken) { var u = await _users.FindByIdAsync(id); @@ -173,6 +175,7 @@ public sealed class UsersController : ControllerBase public sealed record SendTestEmailRequest(string? ToEmail, string? Subject, string? Message); [HttpPost("send-test-email")] + [EnableRateLimiting("auth-email")] public async Task<IActionResult> SendTestEmail([FromBody] SendTestEmailRequest? request, CancellationToken cancellationToken) { var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 9b591ed..512dbd2 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; +using JobTrackerApi.Controllers; using JobTrackerApi.Data; using System.Data.Common; using MySqlConnector; @@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.IdentityModel.Tokens; using JobTrackerApi.Models; using JobTrackerApi.Services; @@ -15,6 +17,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Net; using System.IO; using System.Security.Cryptography; +using System.Threading.RateLimiting; using JobTrackerApi.Services.JobImport; using JobTrackerApi.Services.JobImport.Plugins; using JobTrackerApi.Services.JobImport.Translation; @@ -30,10 +33,13 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped<ICurrentUserService, CurrentUserService>(); builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>(); builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>(); +builder.Services.AddSingleton<ICvProcessingQueue, CvProcessingQueue>(); +builder.Services.AddTransient<ProfileCvController>(); builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>(); builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>(); builder.Services.AddSingleton<AppPaths>(); +builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>(); // Add DbContext builder.Services.AddDbContext<JobTrackerContext>((sp, options) => @@ -79,13 +85,17 @@ builder.Services.AddCors(options => if (origins.Any(x => x.Trim() == "*")) { - policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); + policy.SetIsOriginAllowed(_ => true) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); } else { policy.WithOrigins(origins.Select(x => x.Trim()).Where(x => x.Length > 0).ToArray()) .AllowAnyMethod() - .AllowAnyHeader(); + .AllowAnyHeader() + .AllowCredentials(); } }); }); @@ -113,6 +123,7 @@ builder.Services.AddHostedService<FollowUpReminderHostedService>(); builder.Services.AddHostedService<DailyExportHostedService>(); builder.Services.AddHostedService<JobEnrichmentHostedService>(); builder.Services.AddHostedService<SummarizerProbeHostedService>(); +builder.Services.AddHostedService<CvProcessingHostedService>(); builder.Services.AddHttpClient("jobimport") .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler @@ -133,6 +144,7 @@ builder.Services.AddHttpClient("ai-service", client => builder.Services.AddMemoryCache(); builder.Services.AddSingleton<ISummarizerService, SummarizerService>(); builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>(); +builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>(); builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>(); builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>(); builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>(); @@ -225,6 +237,23 @@ builder.Services.AddAuthentication(options => }) .AddJwtBearer("local", options => { + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + if (!string.IsNullOrWhiteSpace(context.Token)) + { + return Task.CompletedTask; + } + + if (context.Request.Cookies.TryGetValue(AuthSessionOptions.SessionCookieName, out var cookieToken) && !string.IsNullOrWhiteSpace(cookieToken)) + { + context.Token = cookieToken; + } + + return Task.CompletedTask; + } + }; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, @@ -267,6 +296,33 @@ builder.Services.AddAuthorization(options => } }); +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + options.AddPolicy("auth-login", context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: $"login:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromMinutes(5), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0, + })); + + options.AddPolicy("auth-email", context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: $"email:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(15), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0, + })); +}); + var app = builder.Build(); if (ephemeralJwtKey) @@ -314,966 +370,47 @@ app.Use(async (ctx, next) => } }); -// Apply EF migrations on startup (SQLite dev DB lives in the repo). -using (var scope = app.Services.CreateScope()) -{ - var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>(); - var paths = scope.ServiceProvider.GetRequiredService<AppPaths>(); - var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>(); - var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>(); - var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant(); - var useSqliteBootstrap = provider is not "mysql" and not "mariadb"; - - static void EnsureIdentityTablesMySql(DbConnection c) - { - using var cmd = c.CreateCommand(); - cmd.CommandText = @" -CREATE TABLE IF NOT EXISTS `AspNetRoles` ( - `Id` varchar(255) NOT NULL, - `Name` varchar(256) NULL, - `NormalizedName` varchar(256) NULL, - `ConcurrencyStamp` longtext NULL, - PRIMARY KEY (`Id`) -) CHARACTER SET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `AspNetUsers` ( - `Id` varchar(255) NOT NULL, - `UserName` varchar(256) NULL, - `NormalizedUserName` varchar(256) NULL, - `Email` varchar(256) NULL, - `NormalizedEmail` varchar(256) NULL, - `EmailConfirmed` tinyint(1) NOT NULL, - `PasswordHash` longtext NULL, - `SecurityStamp` longtext NULL, - `ConcurrencyStamp` longtext NULL, - `PhoneNumber` longtext NULL, - `PhoneNumberConfirmed` tinyint(1) NOT NULL, - `TwoFactorEnabled` tinyint(1) NOT NULL, - `LockoutEnd` datetime(6) NULL, - `LockoutEnabled` tinyint(1) NOT NULL, - `AccessFailedCount` int NOT NULL, - `FirstName` longtext NULL, - `LastName` longtext NULL, - `DisplayName` longtext NULL, - `ProfileCvText` longtext NULL, - `ProfileCvStructureJson` longtext NULL, - `CurrentCvUploadArtifactId` int NULL, - `CurrentCvExtractionRunId` int NULL, - `CurrentCvProfileVersion` int NULL, - `AvatarImageDataUrl` longtext NULL, - `GoogleSubject` longtext NULL, - `GoogleEmail` longtext NULL, - `GoogleLinkedAt` datetime(6) NULL, - PRIMARY KEY (`Id`) -) CHARACTER SET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `AspNetRoleClaims` ( - `Id` int NOT NULL AUTO_INCREMENT, - `RoleId` varchar(255) NOT NULL, - `ClaimType` longtext NULL, - `ClaimValue` longtext NULL, - PRIMARY KEY (`Id`), - CONSTRAINT `FK_AspNetRoleClaims_AspNetRoles_RoleId` FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE -) CHARACTER SET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `AspNetUserClaims` ( - `Id` int NOT NULL AUTO_INCREMENT, - `UserId` varchar(255) NOT NULL, - `ClaimType` longtext NULL, - `ClaimValue` longtext NULL, - PRIMARY KEY (`Id`), - CONSTRAINT `FK_AspNetUserClaims_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE -) CHARACTER SET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `AspNetUserLogins` ( - `LoginProvider` varchar(255) NOT NULL, - `ProviderKey` varchar(255) NOT NULL, - `ProviderDisplayName` longtext NULL, - `UserId` varchar(255) NOT NULL, - PRIMARY KEY (`LoginProvider`, `ProviderKey`), - CONSTRAINT `FK_AspNetUserLogins_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE -) CHARACTER SET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `AspNetUserRoles` ( - `UserId` varchar(255) NOT NULL, - `RoleId` varchar(255) NOT NULL, - PRIMARY KEY (`UserId`, `RoleId`), - CONSTRAINT `FK_AspNetUserRoles_AspNetRoles_RoleId` FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE, - CONSTRAINT `FK_AspNetUserRoles_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE -) CHARACTER SET=utf8mb4; - -CREATE TABLE IF NOT EXISTS `AspNetUserTokens` ( - `UserId` varchar(255) NOT NULL, - `LoginProvider` varchar(255) NOT NULL, - `Name` varchar(255) NOT NULL, - `Value` longtext NULL, - PRIMARY KEY (`UserId`, `LoginProvider`, `Name`), - CONSTRAINT `FK_AspNetUserTokens_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE -) CHARACTER SET=utf8mb4; - -CREATE UNIQUE INDEX IF NOT EXISTS `RoleNameIndex` ON `AspNetRoles` (`NormalizedName`); -CREATE INDEX IF NOT EXISTS `IX_AspNetRoleClaims_RoleId` ON `AspNetRoleClaims` (`RoleId`); -CREATE INDEX IF NOT EXISTS `EmailIndex` ON `AspNetUsers` (`NormalizedEmail`); -CREATE UNIQUE INDEX IF NOT EXISTS `UserNameIndex` ON `AspNetUsers` (`NormalizedUserName`); -CREATE INDEX IF NOT EXISTS `IX_AspNetUserClaims_UserId` ON `AspNetUserClaims` (`UserId`); -CREATE INDEX IF NOT EXISTS `IX_AspNetUserLogins_UserId` ON `AspNetUserLogins` (`UserId`); -CREATE INDEX IF NOT EXISTS `IX_AspNetUserRoles_RoleId` ON `AspNetUserRoles` (`RoleId`); -"; - cmd.ExecuteNonQuery(); - } - - if (useSqliteBootstrap) - { - // Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied). - // If the schema already contains the columns added by migration 20260310195000, record that migration - // so EF doesn't try to apply it again and fail on duplicate columns. - const string legacyMigrationId = "20260310195000_AddJobFieldsAndSoftDelete"; - const string legacyProductVersion = "7.0.17"; - - using DbConnection conn = db.Database.GetDbConnection(); - conn.Open(); - - static bool HasTable(DbConnection c, string table) - { - using var cmd = c.CreateCommand(); - cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;"; - var p = cmd.CreateParameter(); - p.ParameterName = "$name"; - p.Value = table; - cmd.Parameters.Add(p); - return cmd.ExecuteScalar() is not null; - } - - static bool HasColumn(DbConnection c, string table, string column) - { - using var cmd = c.CreateCommand(); - cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;"; - return cmd.ExecuteScalar() is not null; - } - - static bool HasMigration(DbConnection c, string migrationId) - { - if (!HasTable(c, "__EFMigrationsHistory")) return false; - using var cmd = c.CreateCommand(); - cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;"; - var p = cmd.CreateParameter(); - p.ParameterName = "$id"; - p.Value = migrationId; - cmd.Parameters.Add(p); - return cmd.ExecuteScalar() is not null; - } - - static void Exec(DbConnection c, string sql) - { - using var cmd = c.CreateCommand(); - cmd.CommandText = sql; - cmd.ExecuteNonQuery(); - } - - static void EnsureColumn(DbConnection c, string table, string column, string ddl) - { - // Fresh databases won't have the table until EF migrations run. - if (!HasTable(c, table)) return; - if (!HasColumn(c, table, column)) Exec(c, ddl); - } - - static void EnsureIdentityTables(DbConnection c) - { - // EF migrations are used for the app schema. In some environments `dotnet ef` isn’t available, - // so create the ASP.NET Core Identity tables directly if they don’t exist yet. - if (HasTable(c, "AspNetUsers")) return; - - Exec(c, """ -CREATE TABLE IF NOT EXISTS "AspNetRoles" ( - "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY, - "Name" TEXT NULL, - "NormalizedName" TEXT NULL, - "ConcurrencyStamp" TEXT NULL -); -"""); - - Exec(c, """ -CREATE TABLE IF NOT EXISTS "AspNetUsers" ( - "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY, - "UserName" TEXT NULL, - "NormalizedUserName" TEXT NULL, - "Email" TEXT NULL, - "NormalizedEmail" TEXT NULL, - "EmailConfirmed" INTEGER NOT NULL, - "PasswordHash" TEXT NULL, - "SecurityStamp" TEXT NULL, - "ConcurrencyStamp" TEXT NULL, - "PhoneNumber" TEXT NULL, - "PhoneNumberConfirmed" INTEGER NOT NULL, - "TwoFactorEnabled" INTEGER NOT NULL, - "LockoutEnd" TEXT NULL, - "LockoutEnabled" INTEGER NOT NULL, - "AccessFailedCount" INTEGER NOT NULL, - "FirstName" TEXT NULL, - "LastName" TEXT NULL, - "DisplayName" TEXT NULL, - "ProfileCvText" TEXT NULL, - "ProfileCvStructureJson" TEXT NULL, - "CurrentCvUploadArtifactId" INTEGER NULL, - "CurrentCvExtractionRunId" INTEGER NULL, - "CurrentCvProfileVersion" INTEGER NULL, - "AvatarImageDataUrl" TEXT NULL, - "GoogleSubject" TEXT NULL, - "GoogleEmail" TEXT NULL, - "GoogleLinkedAt" TEXT NULL -); -"""); - - Exec(c, """ -CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" ( - "Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetRoleClaims" PRIMARY KEY AUTOINCREMENT, - "RoleId" TEXT NOT NULL, - "ClaimType" TEXT NULL, - "ClaimValue" TEXT NULL, - CONSTRAINT "FK_AspNetRoleClaims_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE -); -"""); - - Exec(c, """ -CREATE TABLE IF NOT EXISTS "AspNetUserClaims" ( - "Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY AUTOINCREMENT, - "UserId" TEXT NOT NULL, - "ClaimType" TEXT NULL, - "ClaimValue" TEXT NULL, - CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE -); -"""); - - Exec(c, """ -CREATE TABLE IF NOT EXISTS "AspNetUserLogins" ( - "LoginProvider" TEXT NOT NULL, - "ProviderKey" TEXT NOT NULL, - "ProviderDisplayName" TEXT NULL, - "UserId" TEXT NOT NULL, - CONSTRAINT "PK_AspNetUserLogins" PRIMARY KEY ("LoginProvider", "ProviderKey"), - CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE -); -"""); - - Exec(c, """ -CREATE TABLE IF NOT EXISTS "AspNetUserRoles" ( - "UserId" TEXT NOT NULL, - "RoleId" TEXT NOT NULL, - CONSTRAINT "PK_AspNetUserRoles" PRIMARY KEY ("UserId", "RoleId"), - CONSTRAINT "FK_AspNetUserRoles_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE, - CONSTRAINT "FK_AspNetUserRoles_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE -); -"""); - - Exec(c, """ -CREATE TABLE IF NOT EXISTS "AspNetUserTokens" ( - "UserId" TEXT NOT NULL, - "LoginProvider" TEXT NOT NULL, - "Name" TEXT NOT NULL, - "Value" TEXT NULL, - CONSTRAINT "PK_AspNetUserTokens" PRIMARY KEY ("UserId", "LoginProvider", "Name"), - CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE -); -"""); - - Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail");"""); - Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId");"""); - } - - EnsureIdentityTables(conn); - EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvStructureJson TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvUploadArtifactId INTEGER NULL;"); - EnsureColumn(conn, "AspNetUsers", "CurrentCvExtractionRunId", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvExtractionRunId INTEGER NULL;"); - EnsureColumn(conn, "AspNetUsers", "CurrentCvProfileVersion", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvProfileVersion INTEGER NULL;"); - EnsureColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE AspNetUsers ADD COLUMN AvatarImageDataUrl TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;"); - EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;"); - - static void EnsureUserRuleSettingsTable(DbConnection c) - { - if (HasTable(c, "UserRuleSettings")) return; - - Exec(c, """ -CREATE TABLE IF NOT EXISTS "UserRuleSettings" ( - "OwnerUserId" TEXT NOT NULL CONSTRAINT "PK_UserRuleSettings" PRIMARY KEY, - "AppliedFollowUpDays" INTEGER NOT NULL, - "AppliedGhostDays" INTEGER NOT NULL, - "OfferFollowUpDays" INTEGER NOT NULL, - "OfferGhostDays" INTEGER NOT NULL, - "FeedbackFollowUpDays" INTEGER NOT NULL, - "FeedbackGhostDays" INTEGER NOT NULL -); -"""); - } - - EnsureUserRuleSettingsTable(conn); - - static void EnsureGmailConnectionsTable(DbConnection c) - { - Exec(c, """ -CREATE TABLE IF NOT EXISTS "GmailConnections" ( - "Id" INTEGER NOT NULL CONSTRAINT "PK_GmailConnections" PRIMARY KEY AUTOINCREMENT, - "OwnerUserId" TEXT NOT NULL, - "GmailAddress" TEXT NOT NULL, - "EncryptedRefreshToken" TEXT NOT NULL, - "EncryptedAccessToken" TEXT NULL, - "AccessTokenExpiresAt" TEXT NULL, - "Scope" TEXT NOT NULL, - "ConnectedAt" TEXT NOT NULL, - "LastSyncedAt" TEXT NULL, - "LastSyncAttemptedAt" TEXT NULL, - "LastSyncSucceededAt" TEXT NULL, - "LastSyncMode" TEXT NULL, - "LastSyncSource" TEXT NULL, - "LastSyncStatus" TEXT NULL, - "LastSyncError" TEXT NULL -); -"""); - - Exec(c, """ -CREATE TABLE IF NOT EXISTS "GmailReviewDecisions" ( - "Id" INTEGER NOT NULL CONSTRAINT "PK_GmailReviewDecisions" PRIMARY KEY AUTOINCREMENT, - "OwnerUserId" TEXT NOT NULL, - "ThreadId" TEXT NOT NULL, - "JobApplicationId" INTEGER NULL, - "Decision" TEXT NOT NULL, - "Note" TEXT NULL, - "UpdatedAt" TEXT NOT NULL -); -"""); - - EnsureColumn(c, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncAttemptedAt TEXT NULL;"); - EnsureColumn(c, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSucceededAt TEXT NULL;"); - EnsureColumn(c, "GmailConnections", "LastSyncMode", "ALTER TABLE GmailConnections ADD COLUMN LastSyncMode TEXT NULL;"); - EnsureColumn(c, "GmailConnections", "LastSyncSource", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSource TEXT NULL;"); - EnsureColumn(c, "GmailConnections", "LastSyncStatus", "ALTER TABLE GmailConnections ADD COLUMN LastSyncStatus TEXT NULL;"); - EnsureColumn(c, "GmailConnections", "LastSyncError", "ALTER TABLE GmailConnections ADD COLUMN LastSyncError TEXT NULL;"); - - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");"""); - Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");"""); - } - - static void EnsureCvTables(DbConnection c) - { - Exec(c, """ -CREATE TABLE IF NOT EXISTS "CvUploadArtifacts" ( - "Id" INTEGER NOT NULL CONSTRAINT "PK_CvUploadArtifacts" PRIMARY KEY AUTOINCREMENT, - "OwnerUserId" TEXT NOT NULL, - "OriginalFileName" TEXT NOT NULL, - "StoredFileName" TEXT NOT NULL, - "MimeType" TEXT NOT NULL, - "ByteSize" INTEGER NOT NULL, - "Sha256" TEXT NOT NULL, - "StoragePath" TEXT NOT NULL, - "UploadedAtUtc" TEXT NOT NULL -); -"""); - - Exec(c, """ -CREATE TABLE IF NOT EXISTS "CvExtractionRuns" ( - "Id" INTEGER NOT NULL CONSTRAINT "PK_CvExtractionRuns" PRIMARY KEY AUTOINCREMENT, - "OwnerUserId" TEXT NOT NULL, - "ArtifactId" INTEGER NULL, - "Trigger" TEXT NOT NULL, - "ParserVersion" TEXT NOT NULL, - "NormalizerVersion" TEXT NOT NULL, - "LlmPromptVersion" TEXT NOT NULL, - "Status" TEXT NOT NULL, - "RawExtractedText" TEXT NULL, - "NormalizedText" TEXT NULL, - "StructuredProfileJson" TEXT NULL, - "ErrorMessage" TEXT NULL, - "StartedAtUtc" TEXT NOT NULL, - "CompletedAtUtc" TEXT NULL, - "AppliedAtUtc" TEXT NULL, - CONSTRAINT "FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId" FOREIGN KEY ("ArtifactId") REFERENCES "CvUploadArtifacts" ("Id") ON DELETE SET NULL -); -"""); - - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc" ON "CvUploadArtifacts" ("OwnerUserId", "UploadedAtUtc");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc" ON "CvExtractionRuns" ("OwnerUserId", "StartedAtUtc");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_ArtifactId" ON "CvExtractionRuns" ("ArtifactId");"""); - - Exec(c, """ -CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" ( - "Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredCvDrafts" PRIMARY KEY AUTOINCREMENT, - "OwnerUserId" TEXT NOT NULL, - "JobApplicationId" INTEGER NOT NULL, - "CanonicalProfileVersion" INTEGER NULL, - "TemplateId" TEXT NOT NULL, - "Headline" TEXT NULL, - "SummaryJson" TEXT NULL, - "SelectedSkillsJson" TEXT NULL, - "ExperienceJson" TEXT NULL, - "EducationJson" TEXT NULL, - "CustomSectionsJson" TEXT NULL, - "RenderOptionsJson" TEXT NULL, - "GenerationContextHash" TEXT NULL, - "LastGeneratedAtUtc" TEXT NULL, - "LastEditedAtUtc" TEXT NULL, - "Status" TEXT NOT NULL, - CONSTRAINT "FK_TailoredCvDrafts_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE -); -"""); - - Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId" ON "TailoredCvDrafts" ("OwnerUserId", "JobApplicationId");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");"""); - } - - EnsureGmailConnectionsTable(conn); - EnsureCvTables(conn); - - // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, - // and at least one of the new columns already exists. - var isLegacy = - HasMigration(conn, "20260310174114_AddCorrespondence") && - !HasMigration(conn, legacyMigrationId) && - (HasColumn(conn, "Companies", "Source") || HasColumn(conn, "JobApplications", "IsDeleted")); - - if (isLegacy) - { - EnsureColumn(conn, "Companies", "Source", "ALTER TABLE Companies ADD COLUMN Source TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE JobApplications ADD COLUMN IsDeleted INTEGER NOT NULL DEFAULT 0;"); - EnsureColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE JobApplications ADD COLUMN DeletedAt TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "Location", "ALTER TABLE JobApplications ADD COLUMN Location TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "Salary", "ALTER TABLE JobApplications ADD COLUMN Salary TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "NextAction", "ALTER TABLE JobApplications ADD COLUMN NextAction TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE JobApplications ADD COLUMN FollowUpAt TEXT NULL;"); - - // Ensure the persisted short summary column exists for older dev DBs. - EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;"); - - // Multi-user support: scope data to the authenticated user. - EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;"); - - // Legacy DBs may be missing later correspondence columns (Subject/Channel). - if (HasTable(conn, "Correspondences")) - { - EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;"); - } - - // Record the migration as applied. - Exec( - conn, - "INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) " + - $"VALUES ('{legacyMigrationId}', '{legacyProductVersion}');" - ); - } - - // Some dev DBs may not match the "legacy" fingerprint above but still lack - // the ShortSummary column. Ensure it exists unconditionally if missing. - EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;"); - - // Ensure ownership columns exist even on non-legacy DBs. - EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;"); - EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;"); - EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;"); - EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;"); - EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;"); - - // Ensure data folder exists before creating/opening SQLite files. - Directory.CreateDirectory(paths.DataRoot); - } - else - { - var cs = app.Configuration.GetConnectionString("JobTracker"); - if (!string.IsNullOrWhiteSpace(cs)) - { - using var conn = new MySqlConnection(cs); - conn.Open(); - EnsureIdentityTablesMySql(conn); - - static bool MySqlColumnExists(MySqlConnection c, string table, string column) - { - using var cmd = c.CreateCommand(); - cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;"; - - cmd.Parameters.AddWithValue("@schema", c.Database); - cmd.Parameters.AddWithValue("@table", table); - cmd.Parameters.AddWithValue("@column", column); - - return cmd.ExecuteScalar() is not null; - } - - static bool MySqlIndexExists(MySqlConnection c, string table, string indexName) - { - using var cmd = c.CreateCommand(); - cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND INDEX_NAME = @index LIMIT 1;"; - - cmd.Parameters.AddWithValue("@schema", c.Database); - cmd.Parameters.AddWithValue("@table", table); - cmd.Parameters.AddWithValue("@index", indexName); - - return cmd.ExecuteScalar() is not null; - } - - static bool HasMySqlTable(MySqlConnection c, string table) - { - using var cmd = c.CreateCommand(); - cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;"; - cmd.Parameters.AddWithValue("@schema", c.Database); - cmd.Parameters.AddWithValue("@table", table); - return cmd.ExecuteScalar() is not null; - } - - static void EnsureMySqlColumn(MySqlConnection c, string table, string column, string ddl) - { - using var existsCmd = c.CreateCommand(); - existsCmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;"; - existsCmd.Parameters.AddWithValue("@schema", c.Database); - existsCmd.Parameters.AddWithValue("@table", table); - if (existsCmd.ExecuteScalar() is null) return; - - if (MySqlColumnExists(c, table, column)) return; - using var ddlCmd = c.CreateCommand(); - ddlCmd.CommandText = ddl; - ddlCmd.ExecuteNonQuery(); - } - - static bool MySqlIntPrimaryKeyIsAutoIncrement(MySqlConnection c, string table, string column) - { - using var cmd = c.CreateCommand(); - cmd.CommandText = @"SELECT EXTRA -FROM INFORMATION_SCHEMA.COLUMNS -WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column -LIMIT 1;"; - cmd.Parameters.AddWithValue("@schema", c.Database); - cmd.Parameters.AddWithValue("@table", table); - cmd.Parameters.AddWithValue("@column", column); - var extra = cmd.ExecuteScalar()?.ToString() ?? string.Empty; - return extra.Contains("auto_increment", StringComparison.OrdinalIgnoreCase); - } - - static void EnsureMySqlAutoIncrementPrimaryKey(MySqlConnection c, string table, string column) - { - if (!HasMySqlTable(c, table) || !MySqlColumnExists(c, table, column) || MySqlIntPrimaryKeyIsAutoIncrement(c, table, column)) - { - return; - } - - using var cmd = c.CreateCommand(); - cmd.CommandText = $"ALTER TABLE `{table}` MODIFY COLUMN `{column}` int NOT NULL AUTO_INCREMENT;"; - cmd.ExecuteNonQuery(); - } - - EnsureMySqlAutoIncrementPrimaryKey(conn, "Companies", "Id"); - EnsureMySqlAutoIncrementPrimaryKey(conn, "JobApplications", "Id"); - EnsureMySqlAutoIncrementPrimaryKey(conn, "Correspondences", "Id"); - EnsureMySqlAutoIncrementPrimaryKey(conn, "Attachments", "Id"); - EnsureMySqlAutoIncrementPrimaryKey(conn, "JobEvents", "Id"); - EnsureMySqlAutoIncrementPrimaryKey(conn, "GmailConnections", "Id"); - EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id"); - EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id"); - EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id"); - - EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;"); - EnsureMySqlColumn(conn, "Companies", "Source", "ALTER TABLE `Companies` ADD COLUMN `Source` longtext NULL;"); - EnsureMySqlColumn(conn, "Companies", "RecruiterName", "ALTER TABLE `Companies` ADD COLUMN `RecruiterName` longtext NULL;"); - EnsureMySqlColumn(conn, "Companies", "RecruiterEmail", "ALTER TABLE `Companies` ADD COLUMN `RecruiterEmail` longtext NULL;"); - EnsureMySqlColumn(conn, "Companies", "RecruiterLinkedIn", "ALTER TABLE `Companies` ADD COLUMN `RecruiterLinkedIn` longtext NULL;"); - EnsureMySqlColumn(conn, "Companies", "LastContactedAt", "ALTER TABLE `Companies` ADD COLUMN `LastContactedAt` datetime NULL;"); - EnsureMySqlColumn(conn, "Companies", "NextContactAt", "ALTER TABLE `Companies` ADD COLUMN `NextContactAt` datetime NULL;"); - EnsureMySqlColumn(conn, "Companies", "PipelineStage", "ALTER TABLE `Companies` ADD COLUMN `PipelineStage` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE `JobApplications` ADD COLUMN `IsDeleted` tinyint(1) NOT NULL DEFAULT 0;"); - EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE `JobApplications` ADD COLUMN `RecruiterMessageDraft` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "ResponseReceived", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseReceived` tinyint(1) NOT NULL DEFAULT 0;"); - EnsureMySqlColumn(conn, "JobApplications", "ResponseDate", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseDate` datetime NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "Notes", "ALTER TABLE `JobApplications` ADD COLUMN `Notes` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "CoverLetterText", "ALTER TABLE `JobApplications` ADD COLUMN `CoverLetterText` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "JobUrl", "ALTER TABLE `JobApplications` ADD COLUMN `JobUrl` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "Description", "ALTER TABLE `JobApplications` ADD COLUMN `Description` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "TranslatedDescription", "ALTER TABLE `JobApplications` ADD COLUMN `TranslatedDescription` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "DescriptionLanguage", "ALTER TABLE `JobApplications` ADD COLUMN `DescriptionLanguage` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "Tags", "ALTER TABLE `JobApplications` ADD COLUMN `Tags` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "Deadline", "ALTER TABLE `JobApplications` ADD COLUMN `Deadline` datetime NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE `JobApplications` ADD COLUMN `ShortSummary` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvText` longtext NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvUpdatedAt` datetime NULL;"); - EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;"); - EnsureMySqlColumn(conn, "Correspondences", "Subject", "ALTER TABLE `Correspondences` ADD COLUMN `Subject` longtext NULL;"); - EnsureMySqlColumn(conn, "Correspondences", "Channel", "ALTER TABLE `Correspondences` ADD COLUMN `Channel` longtext NULL;"); - EnsureMySqlColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalMessageId` longtext NULL;"); - EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;"); - EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;"); - EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;"); - EnsureMySqlColumn(conn, "Correspondences", "Direction", "ALTER TABLE `Correspondences` ADD COLUMN `Direction` varchar(100) NULL;"); - EnsureMySqlColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;"); - EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` longtext NULL;"); - EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;"); - EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;"); - EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;"); - EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;"); - EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvUploadArtifactId` int NULL;"); - EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvExtractionRunId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvExtractionRunId` int NULL;"); - EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvProfileVersion", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvProfileVersion` int NULL;"); - EnsureMySqlColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE `AspNetUsers` ADD COLUMN `AvatarImageDataUrl` longtext NULL;"); - EnsureMySqlColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;"); - EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;"); - EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;"); - - if (!HasMySqlTable(conn, "RuleSettings")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `RuleSettings` ( -`Id` int NOT NULL, -`AppliedFollowUpDays` int NOT NULL, -`AppliedGhostDays` int NOT NULL, -`OfferFollowUpDays` int NOT NULL, -`OfferGhostDays` int NOT NULL, -`FeedbackFollowUpDays` int NOT NULL, -`FeedbackGhostDays` int NOT NULL, -PRIMARY KEY (`Id`) -);"; - cmd.ExecuteNonQuery(); - } - - using (var seedRuleSettings = conn.CreateCommand()) - { - seedRuleSettings.CommandText = @"INSERT INTO `RuleSettings` (`Id`, `AppliedFollowUpDays`, `AppliedGhostDays`, `OfferFollowUpDays`, `OfferGhostDays`, `FeedbackFollowUpDays`, `FeedbackGhostDays`) -SELECT 1, 14, 30, 7, 14, 7, 14 -WHERE NOT EXISTS (SELECT 1 FROM `RuleSettings` WHERE `Id` = 1);"; - seedRuleSettings.ExecuteNonQuery(); - } - - if (!HasMySqlTable(conn, "UserRuleSettings")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `UserRuleSettings` ( -`OwnerUserId` varchar(255) NOT NULL, -`AppliedFollowUpDays` int NOT NULL, -`AppliedGhostDays` int NOT NULL, -`OfferFollowUpDays` int NOT NULL, -`OfferGhostDays` int NOT NULL, -`FeedbackFollowUpDays` int NOT NULL, -`FeedbackGhostDays` int NOT NULL, -PRIMARY KEY (`OwnerUserId`) -);"; - cmd.ExecuteNonQuery(); - } - - if (!HasMySqlTable(conn, "SystemEmailSettings")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `SystemEmailSettings` ( -`Id` int NOT NULL, -`Enabled` tinyint(1) NULL, -`SmtpHost` longtext NULL, -`SmtpPort` int NULL, -`SmtpUser` longtext NULL, -`SmtpPassword` longtext NULL, -`From` longtext NULL, -`FromName` longtext NULL, -`SmtpEnableSsl` tinyint(1) NULL, -`SmtpTimeoutMs` int NULL, -PRIMARY KEY (`Id`) -);"; - cmd.ExecuteNonQuery(); - } - - if (!HasMySqlTable(conn, "CvUploadArtifacts")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvUploadArtifacts` ( -`Id` int NOT NULL AUTO_INCREMENT, -`OwnerUserId` varchar(255) NOT NULL, -`OriginalFileName` longtext NOT NULL, -`StoredFileName` longtext NOT NULL, -`MimeType` longtext NOT NULL, -`ByteSize` bigint NOT NULL, -`Sha256` longtext NOT NULL, -`StoragePath` longtext NOT NULL, -`UploadedAtUtc` datetime(6) NOT NULL, -PRIMARY KEY (`Id`) -);"; - cmd.ExecuteNonQuery(); - } - - if (!HasMySqlTable(conn, "CvExtractionRuns")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvExtractionRuns` ( -`Id` int NOT NULL AUTO_INCREMENT, -`OwnerUserId` varchar(255) NOT NULL, -`ArtifactId` int NULL, -`Trigger` longtext NOT NULL, -`ParserVersion` longtext NOT NULL, -`NormalizerVersion` longtext NOT NULL, -`LlmPromptVersion` longtext NOT NULL, -`Status` longtext NOT NULL, -`RawExtractedText` longtext NULL, -`NormalizedText` longtext NULL, -`StructuredProfileJson` longtext NULL, -`ErrorMessage` longtext NULL, -`StartedAtUtc` datetime(6) NOT NULL, -`CompletedAtUtc` datetime(6) NULL, -`AppliedAtUtc` datetime(6) NULL, -PRIMARY KEY (`Id`), -CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`ArtifactId`) REFERENCES `CvUploadArtifacts` (`Id`) ON DELETE SET NULL -);"; - cmd.ExecuteNonQuery(); - } - - if (!HasMySqlTable(conn, "GmailConnections")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `GmailConnections` ( -`Id` int NOT NULL AUTO_INCREMENT, -`OwnerUserId` varchar(255) NOT NULL, -`GmailAddress` varchar(512) NOT NULL, -`EncryptedRefreshToken` longtext NOT NULL, -`EncryptedAccessToken` longtext NULL, -`AccessTokenExpiresAt` datetime(6) NULL, -`Scope` longtext NOT NULL, -`ConnectedAt` datetime(6) NOT NULL, -`LastSyncedAt` datetime(6) NULL, -`LastSyncAttemptedAt` datetime(6) NULL, -`LastSyncSucceededAt` datetime(6) NULL, -`LastSyncMode` varchar(255) NULL, -`LastSyncSource` varchar(255) NULL, -`LastSyncStatus` varchar(255) NULL, -`LastSyncError` longtext NULL, -PRIMARY KEY (`Id`) -);"; - cmd.ExecuteNonQuery(); - } - - EnsureMySqlColumn(conn, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncAttemptedAt` datetime(6) NULL;"); - EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSucceededAt` datetime(6) NULL;"); - EnsureMySqlColumn(conn, "GmailConnections", "LastSyncMode", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncMode` varchar(255) NULL;"); - EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSource", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSource` varchar(255) NULL;"); - EnsureMySqlColumn(conn, "GmailConnections", "LastSyncStatus", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncStatus` varchar(255) NULL;"); - EnsureMySqlColumn(conn, "GmailConnections", "LastSyncError", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncError` longtext NULL;"); - - if (!HasMySqlTable(conn, "TailoredCvDrafts")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredCvDrafts` ( -`Id` int NOT NULL AUTO_INCREMENT, -`OwnerUserId` varchar(255) NOT NULL, -`JobApplicationId` int NOT NULL, -`CanonicalProfileVersion` int NULL, -`TemplateId` varchar(100) NOT NULL, -`Headline` longtext NULL, -`SummaryJson` longtext NULL, -`SelectedSkillsJson` longtext NULL, -`ExperienceJson` longtext NULL, -`EducationJson` longtext NULL, -`CustomSectionsJson` longtext NULL, -`RenderOptionsJson` longtext NULL, -`GenerationContextHash` longtext NULL, -`LastGeneratedAtUtc` datetime(6) NULL, -`LastEditedAtUtc` datetime(6) NULL, -`Status` varchar(100) NOT NULL, -PRIMARY KEY (`Id`), -CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE -);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_Companies_OwnerUserId` ON `Companies` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId` ON `JobApplications` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc` ON `CvUploadArtifacts` (`OwnerUserId`, `UploadedAtUtc`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_OwnerUserId_StartedAtUtc` ON `CvExtractionRuns` (`OwnerUserId`, `StartedAtUtc`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_GmailConnections_OwnerUserId` ON `GmailConnections` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_GmailConnections_OwnerUserId_GmailAddress` ON `GmailConnections` (`OwnerUserId`, `GmailAddress`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredCvDrafts_OwnerUserId_JobApplicationId` ON `TailoredCvDrafts` (`OwnerUserId`, `JobApplicationId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_TailoredCvDrafts_JobApplicationId` ON `TailoredCvDrafts` (`JobApplicationId`);"; - cmd.ExecuteNonQuery(); - } - } - } - - db.Database.Migrate(); - - // Optional: seed an initial admin user for local username/password login. - // Set Auth:AdminEmail and Auth:AdminPassword to enable. - var adminEmail = (app.Configuration["Auth:AdminEmail"] ?? "").Trim(); - var adminPassword = (app.Configuration["Auth:AdminPassword"] ?? "").Trim(); - if (!string.IsNullOrWhiteSpace(adminEmail) && !string.IsNullOrWhiteSpace(adminPassword)) - { - const string adminRole = "Admin"; - - if (!roles.RoleExistsAsync(adminRole).GetAwaiter().GetResult()) - { - roles.CreateAsync(new IdentityRole(adminRole)).GetAwaiter().GetResult(); - } - - var existing = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult(); - if (existing is null) - { - var u = new ApplicationUser { UserName = adminEmail, Email = adminEmail, EmailConfirmed = true }; - var created = users.CreateAsync(u, adminPassword).GetAwaiter().GetResult(); - if (created.Succeeded) - { - users.AddToRoleAsync(u, adminRole).GetAwaiter().GetResult(); - app.Logger.LogInformation("Seeded admin user: {Email}", adminEmail); - } - else - { - app.Logger.LogWarning("Failed to seed admin user: {Errors}", string.Join("; ", created.Errors.Select(e => e.Description))); - } - } - else - { - var inRole = users.IsInRoleAsync(existing, adminRole).GetAwaiter().GetResult(); - if (!inRole) users.AddToRoleAsync(existing, adminRole).GetAwaiter().GetResult(); - } - - // One-time claim of legacy data for the admin user so enabling auth doesn't "hide" existing records. - var admin = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult(); - if (admin is not null) - { - try - { - using var conn = db.Database.GetDbConnection(); - conn.Open(); - - static bool ColumnExists(DbConnection c, string providerName, string table, string column) - { - using var cmd = c.CreateCommand(); - if (providerName is "mysql" or "mariadb") - { - var databaseName = c.Database; - cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;"; - - var schemaParam = cmd.CreateParameter(); - schemaParam.ParameterName = "@schema"; - schemaParam.Value = databaseName; - cmd.Parameters.Add(schemaParam); - - var tableParam = cmd.CreateParameter(); - tableParam.ParameterName = "@table"; - tableParam.Value = table; - cmd.Parameters.Add(tableParam); - - var columnParam = cmd.CreateParameter(); - columnParam.ParameterName = "@column"; - columnParam.Value = column; - cmd.Parameters.Add(columnParam); - } - else - { - cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;"; - } - - return cmd.ExecuteScalar() is not null; - } - - var companyOwnershipExists = ColumnExists(conn, provider, "Companies", "OwnerUserId"); - var jobOwnershipExists = ColumnExists(conn, provider, "JobApplications", "OwnerUserId"); - - if (companyOwnershipExists || jobOwnershipExists) - { - if (companyOwnershipExists) - { - db.Database.ExecuteSqlRaw("UPDATE Companies SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id); - } - - if (jobOwnershipExists) - { - db.Database.ExecuteSqlRaw("UPDATE JobApplications SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id); - } - } - } - catch (Exception ex) - { - app.Logger.LogWarning(ex, "Skipping legacy ownership claim because the current schema does not support it yet."); - } - } - } -} +await app.InitializeJobTrackerAsync(); app.UseCors("AllowReact"); +app.UseRateLimiter(); + +app.Use(async (ctx, next) => +{ + if (HttpMethods.IsGet(ctx.Request.Method) || HttpMethods.IsHead(ctx.Request.Method) || HttpMethods.IsOptions(ctx.Request.Method) || HttpMethods.IsTrace(ctx.Request.Method)) + { + await next(); + return; + } + + if (!ctx.Request.Cookies.ContainsKey(AuthSessionOptions.SessionCookieName)) + { + await next(); + return; + } + + if (ctx.Request.Path.StartsWithSegments("/api/auth/login") + || ctx.Request.Path.StartsWithSegments("/api/auth/register") + || ctx.Request.Path.StartsWithSegments("/api/auth/google/exchange") + || ctx.Request.Path.StartsWithSegments("/api/auth/request-password-reset") + || ctx.Request.Path.StartsWithSegments("/api/auth/reset-password") + || ctx.Request.Path.StartsWithSegments("/api/auth/csrf")) + { + await next(); + return; + } + + var csrfCookie = ctx.Request.Cookies[AuthSessionOptions.CsrfCookieName]; + var csrfHeader = ctx.Request.Headers[AuthSessionOptions.CsrfHeaderName].ToString(); + if (string.IsNullOrWhiteSpace(csrfCookie) || string.IsNullOrWhiteSpace(csrfHeader) || !string.Equals(csrfCookie, csrfHeader, StringComparison.Ordinal)) + { + ctx.Response.StatusCode = StatusCodes.Status403Forbidden; + await ctx.Response.WriteAsync("CSRF validation failed."); + return; + } + + await next(); +}); app.UseAuthentication(); app.UseAuthorization(); diff --git a/JobTrackerApi/Services/AuthSessionOptions.cs b/JobTrackerApi/Services/AuthSessionOptions.cs new file mode 100644 index 0000000..61b0a03 --- /dev/null +++ b/JobTrackerApi/Services/AuthSessionOptions.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Http; + +namespace JobTrackerApi.Services; + +public static class AuthSessionOptions +{ + public const string SessionCookieName = "jobtracker_auth"; + public const string CsrfCookieName = "XSRF-TOKEN"; + public const string CsrfHeaderName = "X-CSRF-TOKEN"; + + public static CookieOptions BuildSessionCookie(bool persistent, bool secure) + { + var options = new CookieOptions + { + HttpOnly = true, + IsEssential = true, + SameSite = SameSiteMode.Lax, + Secure = secure, + Path = "/", + }; + + if (persistent) + { + options.Expires = DateTimeOffset.UtcNow.AddDays(30); + options.MaxAge = TimeSpan.FromDays(30); + } + + return options; + } + + public static CookieOptions BuildCsrfCookie(bool persistent, bool secure) + { + var options = new CookieOptions + { + HttpOnly = false, + IsEssential = true, + SameSite = SameSiteMode.Lax, + Secure = secure, + Path = "/", + }; + + if (persistent) + { + options.Expires = DateTimeOffset.UtcNow.AddDays(30); + options.MaxAge = TimeSpan.FromDays(30); + } + + return options; + } + + public static CookieOptions BuildExpiredCookie(bool secure) + { + return new CookieOptions + { + HttpOnly = true, + IsEssential = true, + SameSite = SameSiteMode.Lax, + Secure = secure, + Path = "/", + Expires = DateTimeOffset.UnixEpoch, + MaxAge = TimeSpan.Zero, + }; + } + + public static CookieOptions BuildExpiredReadableCookie(bool secure) + { + return new CookieOptions + { + HttpOnly = false, + IsEssential = true, + SameSite = SameSiteMode.Lax, + Secure = secure, + Path = "/", + Expires = DateTimeOffset.UnixEpoch, + MaxAge = TimeSpan.Zero, + }; + } +} diff --git a/JobTrackerApi/Services/CvAiNormalizer.cs b/JobTrackerApi/Services/CvAiNormalizer.cs new file mode 100644 index 0000000..53cb561 --- /dev/null +++ b/JobTrackerApi/Services/CvAiNormalizer.cs @@ -0,0 +1,58 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JobTrackerApi.Services; + +public sealed record CvNormalizationResult( + double? Confidence, + string? Reason, + [property: JsonPropertyName("normalized_text")] string? NormalizedText); + +public interface ICvAiNormalizer +{ + Task<CvNormalizationResult?> NormalizeAsync(string text, CancellationToken cancellationToken = default); +} + +public sealed class CvAiNormalizer : ICvAiNormalizer +{ + private readonly IHttpClientFactory _httpClientFactory; + + public CvAiNormalizer(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + public async Task<CvNormalizationResult?> NormalizeAsync(string text, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(text)) return null; + + try + { + var client = _httpClientFactory.CreateClient("ai-service"); + var payload = JsonSerializer.Serialize(new { text }); + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync("/cv/normalize", content, cancellationToken); + if (!response.IsSuccessStatusCode) return null; + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync<CvNormalizationResult>(stream, new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }, cancellationToken); + } + catch + { + return null; + } + } +} + +public sealed class NoOpCvAiNormalizer : ICvAiNormalizer +{ + public static NoOpCvAiNormalizer Instance { get; } = new(); + private NoOpCvAiNormalizer() { } + public Task<CvNormalizationResult?> NormalizeAsync(string text, CancellationToken cancellationToken = default) + => Task.FromResult<CvNormalizationResult?>(null); +} diff --git a/JobTrackerApi/Services/CvProcessingQueue.cs b/JobTrackerApi/Services/CvProcessingQueue.cs new file mode 100644 index 0000000..e374979 --- /dev/null +++ b/JobTrackerApi/Services/CvProcessingQueue.cs @@ -0,0 +1,71 @@ +using System.Threading.Channels; +using JobTrackerApi.Controllers; + +namespace JobTrackerApi.Services; + +public interface ICvProcessingQueue +{ + ValueTask EnqueueAsync(int runId, CancellationToken cancellationToken = default); + IAsyncEnumerable<int> DequeueAllAsync(CancellationToken cancellationToken); +} + +public sealed class CvProcessingQueue : ICvProcessingQueue +{ + private readonly Channel<int> _channel = Channel.CreateUnbounded<int>(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + + public ValueTask EnqueueAsync(int runId, CancellationToken cancellationToken = default) + => _channel.Writer.WriteAsync(runId, cancellationToken); + + public IAsyncEnumerable<int> DequeueAllAsync(CancellationToken cancellationToken) + => _channel.Reader.ReadAllAsync(cancellationToken); +} + +public sealed class NoOpCvProcessingQueue : ICvProcessingQueue +{ + public static readonly NoOpCvProcessingQueue Instance = new(); + public ValueTask EnqueueAsync(int runId, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + public async IAsyncEnumerable<int> DequeueAllAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield break; + } +} + +public sealed class CvProcessingHostedService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ICvProcessingQueue _queue; + private readonly ILogger<CvProcessingHostedService> _logger; + + public CvProcessingHostedService(IServiceScopeFactory scopeFactory, ICvProcessingQueue queue, ILogger<CvProcessingHostedService> logger) + { + _scopeFactory = scopeFactory; + _queue = queue; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var runId in _queue.DequeueAllAsync(stoppingToken)) + { + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var controller = scope.ServiceProvider.GetRequiredService<ProfileCvController>(); + await controller.ProcessQueuedRunAsync(runId, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled CV processing worker failure for run {RunId}", runId); + } + } + } +} diff --git a/JobTrackerApi/Services/DailyExportHostedService.cs b/JobTrackerApi/Services/DailyExportHostedService.cs index b2a12f5..060a253 100644 --- a/JobTrackerApi/Services/DailyExportHostedService.cs +++ b/JobTrackerApi/Services/DailyExportHostedService.cs @@ -10,22 +10,26 @@ namespace JobTrackerApi.Services private readonly ILogger<DailyExportHostedService> _logger; private readonly IConfiguration _cfg; private readonly AppPaths _paths; + private readonly IStartupReadiness _startupReadiness; public DailyExportHostedService( IServiceProvider sp, ILogger<DailyExportHostedService> logger, IConfiguration cfg, - AppPaths paths) + AppPaths paths, + IStartupReadiness startupReadiness) { _sp = sp; _logger = logger; _cfg = cfg; _paths = paths; + _startupReadiness = startupReadiness; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var enabled = _cfg.GetValue("Exports:DailyEnabled", true); + await _startupReadiness.WaitUntilReadyAsync(stoppingToken); if (!enabled) { _logger.LogInformation("Daily export disabled (Exports:DailyEnabled=false)."); diff --git a/JobTrackerApi/Services/FollowUpReminderHostedService.cs b/JobTrackerApi/Services/FollowUpReminderHostedService.cs index bd9290f..f632fd3 100644 --- a/JobTrackerApi/Services/FollowUpReminderHostedService.cs +++ b/JobTrackerApi/Services/FollowUpReminderHostedService.cs @@ -10,16 +10,19 @@ public sealed class FollowUpReminderHostedService : BackgroundService private readonly IServiceProvider _services; private readonly IConfiguration _cfg; private readonly ILogger<FollowUpReminderHostedService> _logger; + private readonly IStartupReadiness _startupReadiness; - public FollowUpReminderHostedService(IServiceProvider services, IConfiguration cfg, ILogger<FollowUpReminderHostedService> logger) + public FollowUpReminderHostedService(IServiceProvider services, IConfiguration cfg, ILogger<FollowUpReminderHostedService> logger, IStartupReadiness startupReadiness) { _services = services; _cfg = cfg; _logger = logger; + _startupReadiness = startupReadiness; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + await _startupReadiness.WaitUntilReadyAsync(stoppingToken); await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); while (!stoppingToken.IsCancellationRequested) diff --git a/JobTrackerApi/Services/JobEnrichmentHostedService.cs b/JobTrackerApi/Services/JobEnrichmentHostedService.cs index 1c4d4f4..7875103 100644 --- a/JobTrackerApi/Services/JobEnrichmentHostedService.cs +++ b/JobTrackerApi/Services/JobEnrichmentHostedService.cs @@ -9,15 +9,18 @@ public sealed class JobEnrichmentHostedService : BackgroundService { private readonly IServiceProvider _services; private readonly ILogger<JobEnrichmentHostedService> _logger; + private readonly IStartupReadiness _startupReadiness; - public JobEnrichmentHostedService(IServiceProvider services, ILogger<JobEnrichmentHostedService> logger) + public JobEnrichmentHostedService(IServiceProvider services, ILogger<JobEnrichmentHostedService> logger, IStartupReadiness startupReadiness) { _services = services; _logger = logger; + _startupReadiness = startupReadiness; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + await _startupReadiness.WaitUntilReadyAsync(stoppingToken); await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); while (!stoppingToken.IsCancellationRequested) diff --git a/JobTrackerApi/Services/RulesHostedService.cs b/JobTrackerApi/Services/RulesHostedService.cs index 486e06f..b0cbb3a 100644 --- a/JobTrackerApi/Services/RulesHostedService.cs +++ b/JobTrackerApi/Services/RulesHostedService.cs @@ -7,14 +7,17 @@ namespace JobTrackerApi.Services public sealed class RulesHostedService : BackgroundService { private readonly IServiceProvider _services; + private readonly IStartupReadiness _startupReadiness; - public RulesHostedService(IServiceProvider services) + public RulesHostedService(IServiceProvider services, IStartupReadiness startupReadiness) { _services = services; + _startupReadiness = startupReadiness; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + await _startupReadiness.WaitUntilReadyAsync(stoppingToken); // Small initial delay to let app start. await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs new file mode 100644 index 0000000..b0dd212 --- /dev/null +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -0,0 +1,1004 @@ +using System.Data.Common; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +public static class StartupInitializationExtensions +{ + public static Task InitializeJobTrackerAsync(this WebApplication app) + { + // Apply EF migrations on startup (SQLite dev DB lives in the repo). + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>(); + var paths = scope.ServiceProvider.GetRequiredService<AppPaths>(); + var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>(); + var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>(); + var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant(); + var useSqliteBootstrap = provider is not "mysql" and not "mariadb"; + + static void EnsureIdentityTablesMySql(DbConnection c) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = @" + CREATE TABLE IF NOT EXISTS `AspNetRoles` ( + `Id` varchar(255) NOT NULL, + `Name` varchar(256) NULL, + `NormalizedName` varchar(256) NULL, + `ConcurrencyStamp` longtext NULL, + PRIMARY KEY (`Id`) + ) CHARACTER SET=utf8mb4; + + CREATE TABLE IF NOT EXISTS `AspNetUsers` ( + `Id` varchar(255) NOT NULL, + `UserName` varchar(256) NULL, + `NormalizedUserName` varchar(256) NULL, + `Email` varchar(256) NULL, + `NormalizedEmail` varchar(256) NULL, + `EmailConfirmed` tinyint(1) NOT NULL, + `PasswordHash` longtext NULL, + `SecurityStamp` longtext NULL, + `ConcurrencyStamp` longtext NULL, + `PhoneNumber` longtext NULL, + `PhoneNumberConfirmed` tinyint(1) NOT NULL, + `TwoFactorEnabled` tinyint(1) NOT NULL, + `LockoutEnd` datetime(6) NULL, + `LockoutEnabled` tinyint(1) NOT NULL, + `AccessFailedCount` int NOT NULL, + `FirstName` longtext NULL, + `LastName` longtext NULL, + `DisplayName` longtext NULL, + `ProfileCvText` longtext NULL, + `ProfileCvStructureJson` longtext NULL, + `CurrentCvUploadArtifactId` int NULL, + `CurrentCvExtractionRunId` int NULL, + `CurrentCvProfileVersion` int NULL, + `AvatarImageDataUrl` longtext NULL, + `GoogleSubject` longtext NULL, + `GoogleEmail` longtext NULL, + `GoogleLinkedAt` datetime(6) NULL, + PRIMARY KEY (`Id`) + ) CHARACTER SET=utf8mb4; + + CREATE TABLE IF NOT EXISTS `AspNetRoleClaims` ( + `Id` int NOT NULL AUTO_INCREMENT, + `RoleId` varchar(255) NOT NULL, + `ClaimType` longtext NULL, + `ClaimValue` longtext NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_AspNetRoleClaims_AspNetRoles_RoleId` FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE + ) CHARACTER SET=utf8mb4; + + CREATE TABLE IF NOT EXISTS `AspNetUserClaims` ( + `Id` int NOT NULL AUTO_INCREMENT, + `UserId` varchar(255) NOT NULL, + `ClaimType` longtext NULL, + `ClaimValue` longtext NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_AspNetUserClaims_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE + ) CHARACTER SET=utf8mb4; + + CREATE TABLE IF NOT EXISTS `AspNetUserLogins` ( + `LoginProvider` varchar(255) NOT NULL, + `ProviderKey` varchar(255) NOT NULL, + `ProviderDisplayName` longtext NULL, + `UserId` varchar(255) NOT NULL, + PRIMARY KEY (`LoginProvider`, `ProviderKey`), + CONSTRAINT `FK_AspNetUserLogins_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE + ) CHARACTER SET=utf8mb4; + + CREATE TABLE IF NOT EXISTS `AspNetUserRoles` ( + `UserId` varchar(255) NOT NULL, + `RoleId` varchar(255) NOT NULL, + PRIMARY KEY (`UserId`, `RoleId`), + CONSTRAINT `FK_AspNetUserRoles_AspNetRoles_RoleId` FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_AspNetUserRoles_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE + ) CHARACTER SET=utf8mb4; + + CREATE TABLE IF NOT EXISTS `AspNetUserTokens` ( + `UserId` varchar(255) NOT NULL, + `LoginProvider` varchar(255) NOT NULL, + `Name` varchar(255) NOT NULL, + `Value` longtext NULL, + PRIMARY KEY (`UserId`, `LoginProvider`, `Name`), + CONSTRAINT `FK_AspNetUserTokens_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE + ) CHARACTER SET=utf8mb4; + + CREATE UNIQUE INDEX IF NOT EXISTS `RoleNameIndex` ON `AspNetRoles` (`NormalizedName`); + CREATE INDEX IF NOT EXISTS `IX_AspNetRoleClaims_RoleId` ON `AspNetRoleClaims` (`RoleId`); + CREATE INDEX IF NOT EXISTS `EmailIndex` ON `AspNetUsers` (`NormalizedEmail`); + CREATE UNIQUE INDEX IF NOT EXISTS `UserNameIndex` ON `AspNetUsers` (`NormalizedUserName`); + CREATE INDEX IF NOT EXISTS `IX_AspNetUserClaims_UserId` ON `AspNetUserClaims` (`UserId`); + CREATE INDEX IF NOT EXISTS `IX_AspNetUserLogins_UserId` ON `AspNetUserLogins` (`UserId`); + CREATE INDEX IF NOT EXISTS `IX_AspNetUserRoles_RoleId` ON `AspNetUserRoles` (`RoleId`); + "; + cmd.ExecuteNonQuery(); + } + + if (useSqliteBootstrap) + { + // Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied). + // If the schema already contains the columns added by migration 20260310195000, record that migration + // so EF doesn't try to apply it again and fail on duplicate columns. + const string legacyMigrationId = "20260310195000_AddJobFieldsAndSoftDelete"; + const string legacyProductVersion = "7.0.17"; + + using DbConnection conn = db.Database.GetDbConnection(); + conn.Open(); + + static bool HasTable(DbConnection c, string table) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;"; + var p = cmd.CreateParameter(); + p.ParameterName = "$name"; + p.Value = table; + cmd.Parameters.Add(p); + return cmd.ExecuteScalar() is not null; + } + + static bool HasColumn(DbConnection c, string table, string column) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;"; + return cmd.ExecuteScalar() is not null; + } + + static bool HasMigration(DbConnection c, string migrationId) + { + if (!HasTable(c, "__EFMigrationsHistory")) return false; + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;"; + var p = cmd.CreateParameter(); + p.ParameterName = "$id"; + p.Value = migrationId; + cmd.Parameters.Add(p); + return cmd.ExecuteScalar() is not null; + } + + static void Exec(DbConnection c, string sql) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + static void EnsureColumn(DbConnection c, string table, string column, string ddl) + { + // Fresh databases won't have the table until EF migrations run. + if (!HasTable(c, table)) return; + if (!HasColumn(c, table, column)) Exec(c, ddl); + } + + static void EnsureIdentityTables(DbConnection c) + { + // EF migrations are used for the app schema. In some environments `dotnet ef` isn’t available, + // so create the ASP.NET Core Identity tables directly if they don’t exist yet. + if (HasTable(c, "AspNetUsers")) return; + + Exec(c, """ + CREATE TABLE IF NOT EXISTS "AspNetRoles" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY, + "Name" TEXT NULL, + "NormalizedName" TEXT NULL, + "ConcurrencyStamp" TEXT NULL + ); + """); + + Exec(c, """ + CREATE TABLE IF NOT EXISTS "AspNetUsers" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY, + "UserName" TEXT NULL, + "NormalizedUserName" TEXT NULL, + "Email" TEXT NULL, + "NormalizedEmail" TEXT NULL, + "EmailConfirmed" INTEGER NOT NULL, + "PasswordHash" TEXT NULL, + "SecurityStamp" TEXT NULL, + "ConcurrencyStamp" TEXT NULL, + "PhoneNumber" TEXT NULL, + "PhoneNumberConfirmed" INTEGER NOT NULL, + "TwoFactorEnabled" INTEGER NOT NULL, + "LockoutEnd" TEXT NULL, + "LockoutEnabled" INTEGER NOT NULL, + "AccessFailedCount" INTEGER NOT NULL, + "FirstName" TEXT NULL, + "LastName" TEXT NULL, + "DisplayName" TEXT NULL, + "ProfileCvText" TEXT NULL, + "ProfileCvStructureJson" TEXT NULL, + "CurrentCvUploadArtifactId" INTEGER NULL, + "CurrentCvExtractionRunId" INTEGER NULL, + "CurrentCvProfileVersion" INTEGER NULL, + "AvatarImageDataUrl" TEXT NULL, + "GoogleSubject" TEXT NULL, + "GoogleEmail" TEXT NULL, + "GoogleLinkedAt" TEXT NULL + ); + """); + + Exec(c, """ + CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetRoleClaims" PRIMARY KEY AUTOINCREMENT, + "RoleId" TEXT NOT NULL, + "ClaimType" TEXT NULL, + "ClaimValue" TEXT NULL, + CONSTRAINT "FK_AspNetRoleClaims_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE + ); + """); + + Exec(c, """ + CREATE TABLE IF NOT EXISTS "AspNetUserClaims" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY AUTOINCREMENT, + "UserId" TEXT NOT NULL, + "ClaimType" TEXT NULL, + "ClaimValue" TEXT NULL, + CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE + ); + """); + + Exec(c, """ + CREATE TABLE IF NOT EXISTS "AspNetUserLogins" ( + "LoginProvider" TEXT NOT NULL, + "ProviderKey" TEXT NOT NULL, + "ProviderDisplayName" TEXT NULL, + "UserId" TEXT NOT NULL, + CONSTRAINT "PK_AspNetUserLogins" PRIMARY KEY ("LoginProvider", "ProviderKey"), + CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE + ); + """); + + Exec(c, """ + CREATE TABLE IF NOT EXISTS "AspNetUserRoles" ( + "UserId" TEXT NOT NULL, + "RoleId" TEXT NOT NULL, + CONSTRAINT "PK_AspNetUserRoles" PRIMARY KEY ("UserId", "RoleId"), + CONSTRAINT "FK_AspNetUserRoles_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_AspNetUserRoles_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE + ); + """); + + Exec(c, """ + CREATE TABLE IF NOT EXISTS "AspNetUserTokens" ( + "UserId" TEXT NOT NULL, + "LoginProvider" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "Value" TEXT NULL, + CONSTRAINT "PK_AspNetUserTokens" PRIMARY KEY ("UserId", "LoginProvider", "Name"), + CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE + ); + """); + + Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail");"""); + Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId");"""); + } + + EnsureIdentityTables(conn); + EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvStructureJson TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvUploadArtifactId INTEGER NULL;"); + EnsureColumn(conn, "AspNetUsers", "CurrentCvExtractionRunId", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvExtractionRunId INTEGER NULL;"); + EnsureColumn(conn, "AspNetUsers", "CurrentCvProfileVersion", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvProfileVersion INTEGER NULL;"); + EnsureColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE AspNetUsers ADD COLUMN AvatarImageDataUrl TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;"); + + static void EnsureUserRuleSettingsTable(DbConnection c) + { + if (HasTable(c, "UserRuleSettings")) return; + + Exec(c, """ + CREATE TABLE IF NOT EXISTS "UserRuleSettings" ( + "OwnerUserId" TEXT NOT NULL CONSTRAINT "PK_UserRuleSettings" PRIMARY KEY, + "AppliedFollowUpDays" INTEGER NOT NULL, + "AppliedGhostDays" INTEGER NOT NULL, + "OfferFollowUpDays" INTEGER NOT NULL, + "OfferGhostDays" INTEGER NOT NULL, + "FeedbackFollowUpDays" INTEGER NOT NULL, + "FeedbackGhostDays" INTEGER NOT NULL + ); + """); + } + + EnsureUserRuleSettingsTable(conn); + + static void EnsureGmailConnectionsTable(DbConnection c) + { + Exec(c, """ + CREATE TABLE IF NOT EXISTS "GmailConnections" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_GmailConnections" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "GmailAddress" TEXT NOT NULL, + "EncryptedRefreshToken" TEXT NOT NULL, + "EncryptedAccessToken" TEXT NULL, + "AccessTokenExpiresAt" TEXT NULL, + "Scope" TEXT NOT NULL, + "ConnectedAt" TEXT NOT NULL, + "LastSyncedAt" TEXT NULL, + "LastSyncAttemptedAt" TEXT NULL, + "LastSyncSucceededAt" TEXT NULL, + "LastSyncMode" TEXT NULL, + "LastSyncSource" TEXT NULL, + "LastSyncStatus" TEXT NULL, + "LastSyncError" TEXT NULL + ); + """); + + Exec(c, """ + CREATE TABLE IF NOT EXISTS "GmailReviewDecisions" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_GmailReviewDecisions" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "ThreadId" TEXT NOT NULL, + "JobApplicationId" INTEGER NULL, + "Decision" TEXT NOT NULL, + "Note" TEXT NULL, + "UpdatedAt" TEXT NOT NULL + ); + """); + + EnsureColumn(c, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncAttemptedAt TEXT NULL;"); + EnsureColumn(c, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSucceededAt TEXT NULL;"); + EnsureColumn(c, "GmailConnections", "LastSyncMode", "ALTER TABLE GmailConnections ADD COLUMN LastSyncMode TEXT NULL;"); + EnsureColumn(c, "GmailConnections", "LastSyncSource", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSource TEXT NULL;"); + EnsureColumn(c, "GmailConnections", "LastSyncStatus", "ALTER TABLE GmailConnections ADD COLUMN LastSyncStatus TEXT NULL;"); + EnsureColumn(c, "GmailConnections", "LastSyncError", "ALTER TABLE GmailConnections ADD COLUMN LastSyncError TEXT NULL;"); + + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");"""); + Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");"""); + } + + static void EnsureCvTables(DbConnection c) + { + Exec(c, """ + CREATE TABLE IF NOT EXISTS "CvUploadArtifacts" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CvUploadArtifacts" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "OriginalFileName" TEXT NOT NULL, + "StoredFileName" TEXT NOT NULL, + "MimeType" TEXT NOT NULL, + "ByteSize" INTEGER NOT NULL, + "Sha256" TEXT NOT NULL, + "StoragePath" TEXT NOT NULL, + "UploadedAtUtc" TEXT NOT NULL + ); + """); + + Exec(c, """ + CREATE TABLE IF NOT EXISTS "CvExtractionRuns" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CvExtractionRuns" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "ArtifactId" INTEGER NULL, + "Trigger" TEXT NOT NULL, + "ParserVersion" TEXT NOT NULL, + "NormalizerVersion" TEXT NOT NULL, + "LlmPromptVersion" TEXT NOT NULL, + "Status" TEXT NOT NULL, + "RawExtractedText" TEXT NULL, + "NormalizedText" TEXT NULL, + "StructuredProfileJson" TEXT NULL, + "ErrorMessage" TEXT NULL, + "StartedAtUtc" TEXT NOT NULL, + "CompletedAtUtc" TEXT NULL, + "AppliedAtUtc" TEXT NULL, + CONSTRAINT "FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId" FOREIGN KEY ("ArtifactId") REFERENCES "CvUploadArtifacts" ("Id") ON DELETE SET NULL + ); + """); + + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc" ON "CvUploadArtifacts" ("OwnerUserId", "UploadedAtUtc");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc" ON "CvExtractionRuns" ("OwnerUserId", "StartedAtUtc");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_ArtifactId" ON "CvExtractionRuns" ("ArtifactId");"""); + + Exec(c, """ + CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredCvDrafts" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "JobApplicationId" INTEGER NOT NULL, + "CanonicalProfileVersion" INTEGER NULL, + "TemplateId" TEXT NOT NULL, + "Headline" TEXT NULL, + "SummaryJson" TEXT NULL, + "SelectedSkillsJson" TEXT NULL, + "ExperienceJson" TEXT NULL, + "EducationJson" TEXT NULL, + "CustomSectionsJson" TEXT NULL, + "RenderOptionsJson" TEXT NULL, + "GenerationContextHash" TEXT NULL, + "LastGeneratedAtUtc" TEXT NULL, + "LastEditedAtUtc" TEXT NULL, + "Status" TEXT NOT NULL, + CONSTRAINT "FK_TailoredCvDrafts_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE + ); + """); + + Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId" ON "TailoredCvDrafts" ("OwnerUserId", "JobApplicationId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");"""); + } + + EnsureGmailConnectionsTable(conn); + EnsureCvTables(conn); + + // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, + // and at least one of the new columns already exists. + var isLegacy = + HasMigration(conn, "20260310174114_AddCorrespondence") && + !HasMigration(conn, legacyMigrationId) && + (HasColumn(conn, "Companies", "Source") || HasColumn(conn, "JobApplications", "IsDeleted")); + + if (isLegacy) + { + EnsureColumn(conn, "Companies", "Source", "ALTER TABLE Companies ADD COLUMN Source TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE JobApplications ADD COLUMN IsDeleted INTEGER NOT NULL DEFAULT 0;"); + EnsureColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE JobApplications ADD COLUMN DeletedAt TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "Location", "ALTER TABLE JobApplications ADD COLUMN Location TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "Salary", "ALTER TABLE JobApplications ADD COLUMN Salary TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "NextAction", "ALTER TABLE JobApplications ADD COLUMN NextAction TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE JobApplications ADD COLUMN FollowUpAt TEXT NULL;"); + + // Ensure the persisted short summary column exists for older dev DBs. + EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;"); + + // Multi-user support: scope data to the authenticated user. + EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;"); + + // Legacy DBs may be missing later correspondence columns (Subject/Channel). + if (HasTable(conn, "Correspondences")) + { + EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;"); + } + + // Record the migration as applied. + Exec( + conn, + "INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) " + + $"VALUES ('{legacyMigrationId}', '{legacyProductVersion}');" + ); + } + + // Some dev DBs may not match the "legacy" fingerprint above but still lack + // the ShortSummary column. Ensure it exists unconditionally if missing. + EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;"); + + // Ensure ownership columns exist even on non-legacy DBs. + EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;"); + EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;"); + EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;"); + + // Ensure data folder exists before creating/opening SQLite files. + Directory.CreateDirectory(paths.DataRoot); + } + else + { + var cs = app.Configuration.GetConnectionString("JobTracker"); + if (!string.IsNullOrWhiteSpace(cs)) + { + using DbConnection conn = db.Database.GetDbConnection(); + conn.Open(); + EnsureIdentityTablesMySql(conn); + + static bool MySqlColumnExists(DbConnection c, string table, string column) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;"; + + var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1); + var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2); + var p3 = cmd.CreateParameter(); p3.ParameterName = "@column"; p3.Value = column; cmd.Parameters.Add(p3); + + return cmd.ExecuteScalar() is not null; + } + + static bool MySqlIndexExists(DbConnection c, string table, string indexName) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND INDEX_NAME = @index LIMIT 1;"; + + var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1); + var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2); + var p4 = cmd.CreateParameter(); p4.ParameterName = "@index"; p4.Value = indexName; cmd.Parameters.Add(p4); + + return cmd.ExecuteScalar() is not null; + } + + static bool HasMySqlTable(DbConnection c, string table) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;"; + var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1); + var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2); + return cmd.ExecuteScalar() is not null; + } + + static void EnsureMySqlColumn(DbConnection c, string table, string column, string ddl) + { + using var existsCmd = c.CreateCommand(); + existsCmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;"; + var ep1 = existsCmd.CreateParameter(); ep1.ParameterName = "@schema"; ep1.Value = c.Database; existsCmd.Parameters.Add(ep1); + var ep2 = existsCmd.CreateParameter(); ep2.ParameterName = "@table"; ep2.Value = table; existsCmd.Parameters.Add(ep2); + if (existsCmd.ExecuteScalar() is null) return; + + if (MySqlColumnExists(c, table, column)) return; + using var ddlCmd = c.CreateCommand(); + ddlCmd.CommandText = ddl; + ddlCmd.ExecuteNonQuery(); + } + + static bool MySqlIntPrimaryKeyIsAutoIncrement(DbConnection c, string table, string column) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = @"SELECT EXTRA + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column + LIMIT 1;"; + var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1); + var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2); + var p3 = cmd.CreateParameter(); p3.ParameterName = "@column"; p3.Value = column; cmd.Parameters.Add(p3); + var extra = cmd.ExecuteScalar()?.ToString() ?? string.Empty; + return extra.Contains("auto_increment", StringComparison.OrdinalIgnoreCase); + } + + static void EnsureMySqlAutoIncrementPrimaryKey(DbConnection c, string table, string column) + { + if (!HasMySqlTable(c, table) || !MySqlColumnExists(c, table, column) || MySqlIntPrimaryKeyIsAutoIncrement(c, table, column)) + { + return; + } + + using var cmd = c.CreateCommand(); + cmd.CommandText = $"ALTER TABLE `{table}` MODIFY COLUMN `{column}` int NOT NULL AUTO_INCREMENT;"; + cmd.ExecuteNonQuery(); + } + + EnsureMySqlAutoIncrementPrimaryKey(conn, "Companies", "Id"); + EnsureMySqlAutoIncrementPrimaryKey(conn, "JobApplications", "Id"); + EnsureMySqlAutoIncrementPrimaryKey(conn, "Correspondences", "Id"); + EnsureMySqlAutoIncrementPrimaryKey(conn, "Attachments", "Id"); + EnsureMySqlAutoIncrementPrimaryKey(conn, "JobEvents", "Id"); + EnsureMySqlAutoIncrementPrimaryKey(conn, "GmailConnections", "Id"); + EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id"); + EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id"); + EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id"); + + EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;"); + EnsureMySqlColumn(conn, "Companies", "Source", "ALTER TABLE `Companies` ADD COLUMN `Source` longtext NULL;"); + EnsureMySqlColumn(conn, "Companies", "RecruiterName", "ALTER TABLE `Companies` ADD COLUMN `RecruiterName` longtext NULL;"); + EnsureMySqlColumn(conn, "Companies", "RecruiterEmail", "ALTER TABLE `Companies` ADD COLUMN `RecruiterEmail` longtext NULL;"); + EnsureMySqlColumn(conn, "Companies", "RecruiterLinkedIn", "ALTER TABLE `Companies` ADD COLUMN `RecruiterLinkedIn` longtext NULL;"); + EnsureMySqlColumn(conn, "Companies", "LastContactedAt", "ALTER TABLE `Companies` ADD COLUMN `LastContactedAt` datetime NULL;"); + EnsureMySqlColumn(conn, "Companies", "NextContactAt", "ALTER TABLE `Companies` ADD COLUMN `NextContactAt` datetime NULL;"); + EnsureMySqlColumn(conn, "Companies", "PipelineStage", "ALTER TABLE `Companies` ADD COLUMN `PipelineStage` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE `JobApplications` ADD COLUMN `IsDeleted` tinyint(1) NOT NULL DEFAULT 0;"); + EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE `JobApplications` ADD COLUMN `RecruiterMessageDraft` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "ResponseReceived", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseReceived` tinyint(1) NOT NULL DEFAULT 0;"); + EnsureMySqlColumn(conn, "JobApplications", "ResponseDate", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseDate` datetime NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "Notes", "ALTER TABLE `JobApplications` ADD COLUMN `Notes` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "CoverLetterText", "ALTER TABLE `JobApplications` ADD COLUMN `CoverLetterText` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "JobUrl", "ALTER TABLE `JobApplications` ADD COLUMN `JobUrl` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "Description", "ALTER TABLE `JobApplications` ADD COLUMN `Description` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "TranslatedDescription", "ALTER TABLE `JobApplications` ADD COLUMN `TranslatedDescription` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "DescriptionLanguage", "ALTER TABLE `JobApplications` ADD COLUMN `DescriptionLanguage` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "Tags", "ALTER TABLE `JobApplications` ADD COLUMN `Tags` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "Deadline", "ALTER TABLE `JobApplications` ADD COLUMN `Deadline` datetime NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE `JobApplications` ADD COLUMN `ShortSummary` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvText` longtext NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvUpdatedAt` datetime NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "Subject", "ALTER TABLE `Correspondences` ADD COLUMN `Subject` longtext NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "Channel", "ALTER TABLE `Correspondences` ADD COLUMN `Channel` longtext NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalMessageId` longtext NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "Direction", "ALTER TABLE `Correspondences` ADD COLUMN `Direction` varchar(100) NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` longtext NULL;"); + EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;"); + EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;"); + EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvUploadArtifactId` int NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvExtractionRunId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvExtractionRunId` int NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvProfileVersion", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvProfileVersion` int NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE `AspNetUsers` ADD COLUMN `AvatarImageDataUrl` longtext NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;"); + + if (!HasMySqlTable(conn, "RuleSettings")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `RuleSettings` ( + `Id` int NOT NULL, + `AppliedFollowUpDays` int NOT NULL, + `AppliedGhostDays` int NOT NULL, + `OfferFollowUpDays` int NOT NULL, + `OfferGhostDays` int NOT NULL, + `FeedbackFollowUpDays` int NOT NULL, + `FeedbackGhostDays` int NOT NULL, + PRIMARY KEY (`Id`) + );"; + cmd.ExecuteNonQuery(); + } + + using (var seedRuleSettings = conn.CreateCommand()) + { + seedRuleSettings.CommandText = @"INSERT INTO `RuleSettings` (`Id`, `AppliedFollowUpDays`, `AppliedGhostDays`, `OfferFollowUpDays`, `OfferGhostDays`, `FeedbackFollowUpDays`, `FeedbackGhostDays`) + SELECT 1, 14, 30, 7, 14, 7, 14 + WHERE NOT EXISTS (SELECT 1 FROM `RuleSettings` WHERE `Id` = 1);"; + seedRuleSettings.ExecuteNonQuery(); + } + + if (!HasMySqlTable(conn, "UserRuleSettings")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `UserRuleSettings` ( + `OwnerUserId` varchar(255) NOT NULL, + `AppliedFollowUpDays` int NOT NULL, + `AppliedGhostDays` int NOT NULL, + `OfferFollowUpDays` int NOT NULL, + `OfferGhostDays` int NOT NULL, + `FeedbackFollowUpDays` int NOT NULL, + `FeedbackGhostDays` int NOT NULL, + PRIMARY KEY (`OwnerUserId`) + );"; + cmd.ExecuteNonQuery(); + } + + if (!HasMySqlTable(conn, "SystemEmailSettings")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `SystemEmailSettings` ( + `Id` int NOT NULL, + `Enabled` tinyint(1) NULL, + `SmtpHost` longtext NULL, + `SmtpPort` int NULL, + `SmtpUser` longtext NULL, + `SmtpPassword` longtext NULL, + `From` longtext NULL, + `FromName` longtext NULL, + `SmtpEnableSsl` tinyint(1) NULL, + `SmtpTimeoutMs` int NULL, + PRIMARY KEY (`Id`) + );"; + cmd.ExecuteNonQuery(); + } + + if (!HasMySqlTable(conn, "CvUploadArtifacts")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvUploadArtifacts` ( + `Id` int NOT NULL AUTO_INCREMENT, + `OwnerUserId` varchar(255) NOT NULL, + `OriginalFileName` longtext NOT NULL, + `StoredFileName` longtext NOT NULL, + `MimeType` longtext NOT NULL, + `ByteSize` bigint NOT NULL, + `Sha256` longtext NOT NULL, + `StoragePath` longtext NOT NULL, + `UploadedAtUtc` datetime(6) NOT NULL, + PRIMARY KEY (`Id`) + );"; + cmd.ExecuteNonQuery(); + } + + if (!HasMySqlTable(conn, "CvExtractionRuns")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvExtractionRuns` ( + `Id` int NOT NULL AUTO_INCREMENT, + `OwnerUserId` varchar(255) NOT NULL, + `ArtifactId` int NULL, + `Trigger` longtext NOT NULL, + `ParserVersion` longtext NOT NULL, + `NormalizerVersion` longtext NOT NULL, + `LlmPromptVersion` longtext NOT NULL, + `Status` longtext NOT NULL, + `RawExtractedText` longtext NULL, + `NormalizedText` longtext NULL, + `StructuredProfileJson` longtext NULL, + `ErrorMessage` longtext NULL, + `StartedAtUtc` datetime(6) NOT NULL, + `CompletedAtUtc` datetime(6) NULL, + `AppliedAtUtc` datetime(6) NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`ArtifactId`) REFERENCES `CvUploadArtifacts` (`Id`) ON DELETE SET NULL + );"; + cmd.ExecuteNonQuery(); + } + + if (!HasMySqlTable(conn, "GmailConnections")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `GmailConnections` ( + `Id` int NOT NULL AUTO_INCREMENT, + `OwnerUserId` varchar(255) NOT NULL, + `GmailAddress` varchar(512) NOT NULL, + `EncryptedRefreshToken` longtext NOT NULL, + `EncryptedAccessToken` longtext NULL, + `AccessTokenExpiresAt` datetime(6) NULL, + `Scope` longtext NOT NULL, + `ConnectedAt` datetime(6) NOT NULL, + `LastSyncedAt` datetime(6) NULL, + `LastSyncAttemptedAt` datetime(6) NULL, + `LastSyncSucceededAt` datetime(6) NULL, + `LastSyncMode` varchar(255) NULL, + `LastSyncSource` varchar(255) NULL, + `LastSyncStatus` varchar(255) NULL, + `LastSyncError` longtext NULL, + PRIMARY KEY (`Id`) + );"; + cmd.ExecuteNonQuery(); + } + + EnsureMySqlColumn(conn, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncAttemptedAt` datetime(6) NULL;"); + EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSucceededAt` datetime(6) NULL;"); + EnsureMySqlColumn(conn, "GmailConnections", "LastSyncMode", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncMode` varchar(255) NULL;"); + EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSource", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSource` varchar(255) NULL;"); + EnsureMySqlColumn(conn, "GmailConnections", "LastSyncStatus", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncStatus` varchar(255) NULL;"); + EnsureMySqlColumn(conn, "GmailConnections", "LastSyncError", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncError` longtext NULL;"); + + if (!HasMySqlTable(conn, "TailoredCvDrafts")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredCvDrafts` ( + `Id` int NOT NULL AUTO_INCREMENT, + `OwnerUserId` varchar(255) NOT NULL, + `JobApplicationId` int NOT NULL, + `CanonicalProfileVersion` int NULL, + `TemplateId` varchar(100) NOT NULL, + `Headline` longtext NULL, + `SummaryJson` longtext NULL, + `SelectedSkillsJson` longtext NULL, + `ExperienceJson` longtext NULL, + `EducationJson` longtext NULL, + `CustomSectionsJson` longtext NULL, + `RenderOptionsJson` longtext NULL, + `GenerationContextHash` longtext NULL, + `LastGeneratedAtUtc` datetime(6) NULL, + `LastEditedAtUtc` datetime(6) NULL, + `Status` varchar(100) NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE + );"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_Companies_OwnerUserId` ON `Companies` (`OwnerUserId`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId` ON `JobApplications` (`OwnerUserId`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc` ON `CvUploadArtifacts` (`OwnerUserId`, `UploadedAtUtc`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_OwnerUserId_StartedAtUtc` ON `CvExtractionRuns` (`OwnerUserId`, `StartedAtUtc`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_GmailConnections_OwnerUserId` ON `GmailConnections` (`OwnerUserId`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE UNIQUE INDEX `IX_GmailConnections_OwnerUserId_GmailAddress` ON `GmailConnections` (`OwnerUserId`, `GmailAddress`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredCvDrafts_OwnerUserId_JobApplicationId` ON `TailoredCvDrafts` (`OwnerUserId`, `JobApplicationId`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_TailoredCvDrafts_JobApplicationId` ON `TailoredCvDrafts` (`JobApplicationId`);"; + cmd.ExecuteNonQuery(); + } + } + } + + db.Database.Migrate(); + + // Optional: seed an initial admin user for local username/password login. + // Set Auth:AdminEmail and Auth:AdminPassword to enable. + var adminEmail = (app.Configuration["Auth:AdminEmail"] ?? "").Trim(); + var adminPassword = (app.Configuration["Auth:AdminPassword"] ?? "").Trim(); + if (!string.IsNullOrWhiteSpace(adminEmail) && !string.IsNullOrWhiteSpace(adminPassword)) + { + const string adminRole = "Admin"; + + if (!roles.RoleExistsAsync(adminRole).GetAwaiter().GetResult()) + { + roles.CreateAsync(new IdentityRole(adminRole)).GetAwaiter().GetResult(); + } + + var existing = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult(); + if (existing is null) + { + var u = new ApplicationUser { UserName = adminEmail, Email = adminEmail, EmailConfirmed = true }; + var created = users.CreateAsync(u, adminPassword).GetAwaiter().GetResult(); + if (created.Succeeded) + { + users.AddToRoleAsync(u, adminRole).GetAwaiter().GetResult(); + app.Logger.LogInformation("Seeded admin user: {Email}", adminEmail); + } + else + { + app.Logger.LogWarning("Failed to seed admin user: {Errors}", string.Join("; ", created.Errors.Select(e => e.Description))); + } + } + else + { + var inRole = users.IsInRoleAsync(existing, adminRole).GetAwaiter().GetResult(); + if (!inRole) users.AddToRoleAsync(existing, adminRole).GetAwaiter().GetResult(); + } + + // One-time claim of legacy data for the admin user so enabling auth doesn't "hide" existing records. + var admin = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult(); + if (admin is not null) + { + try + { + using var conn = db.Database.GetDbConnection(); + conn.Open(); + + static bool ColumnExists(DbConnection c, string providerName, string table, string column) + { + using var cmd = c.CreateCommand(); + if (providerName is "mysql" or "mariadb") + { + var databaseName = c.Database; + cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;"; + + var schemaParam = cmd.CreateParameter(); + schemaParam.ParameterName = "@schema"; + schemaParam.Value = databaseName; + cmd.Parameters.Add(schemaParam); + + var tableParam = cmd.CreateParameter(); + tableParam.ParameterName = "@table"; + tableParam.Value = table; + cmd.Parameters.Add(tableParam); + + var columnParam = cmd.CreateParameter(); + columnParam.ParameterName = "@column"; + columnParam.Value = column; + cmd.Parameters.Add(columnParam); + } + else + { + cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;"; + } + + return cmd.ExecuteScalar() is not null; + } + + var companyOwnershipExists = ColumnExists(conn, provider, "Companies", "OwnerUserId"); + var jobOwnershipExists = ColumnExists(conn, provider, "JobApplications", "OwnerUserId"); + + if (companyOwnershipExists || jobOwnershipExists) + { + if (companyOwnershipExists) + { + db.Database.ExecuteSqlRaw("UPDATE Companies SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id); + } + + if (jobOwnershipExists) + { + db.Database.ExecuteSqlRaw("UPDATE JobApplications SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id); + } + } + } + catch (Exception ex) + { + app.Logger.LogWarning(ex, "Skipping legacy ownership claim because the current schema does not support it yet."); + } + } + } + } + + bool CoreSchemaReady(DbConnection connection, string providerName) + { + using var cmd = connection.CreateCommand(); + if (providerName is "mysql" or "mariadb") + { + cmd.CommandText = "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('JobApplications', 'RuleSettings');"; + return Convert.ToInt32(cmd.ExecuteScalar() ?? 0) == 2; + } + + cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('JobApplications', 'RuleSettings');"; + return Convert.ToInt32(cmd.ExecuteScalar() ?? 0) == 2; + } + + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>(); + var runtimeProvider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant(); + using var conn = db.Database.GetDbConnection(); + conn.Open(); + if (!CoreSchemaReady(conn, runtimeProvider)) + { + app.Logger.LogWarning("Core schema is incomplete after startup initialization. Background services will remain paused until required tables exist."); + return Task.CompletedTask; + } + } + + var readiness = app.Services.GetRequiredService<IStartupReadiness>(); + readiness.MarkReady(); + + return Task.CompletedTask; + } +} diff --git a/JobTrackerApi/Services/StartupReadiness.cs b/JobTrackerApi/Services/StartupReadiness.cs new file mode 100644 index 0000000..dc2fc74 --- /dev/null +++ b/JobTrackerApi/Services/StartupReadiness.cs @@ -0,0 +1,27 @@ +namespace JobTrackerApi.Services; + +public interface IStartupReadiness +{ + Task WaitUntilReadyAsync(CancellationToken cancellationToken); + void MarkReady(); +} + +public sealed class StartupReadiness : IStartupReadiness +{ + private readonly TaskCompletionSource<bool> _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task WaitUntilReadyAsync(CancellationToken cancellationToken) + { + if (_ready.Task.IsCompleted) + { + return Task.CompletedTask; + } + + return _ready.Task.WaitAsync(cancellationToken); + } + + public void MarkReady() + { + _ready.TrySetResult(true); + } +} diff --git a/JobTrackerApi/Services/SummarizerService.cs b/JobTrackerApi/Services/SummarizerService.cs index 76d8070..404a184 100644 --- a/JobTrackerApi/Services/SummarizerService.cs +++ b/JobTrackerApi/Services/SummarizerService.cs @@ -111,6 +111,35 @@ namespace JobTrackerApi.Services return $"summ:{hash}"; } + + private static async Task<string> ReadErrorBodyAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(body)) + { + return $"HTTP {(int)response.StatusCode}"; + } + + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("detail", out var detailEl) && detailEl.ValueKind == JsonValueKind.String) + { + return $"HTTP {(int)response.StatusCode}: {detailEl.GetString()}"; + } + if (doc.RootElement.TryGetProperty("message", out var messageEl) && messageEl.ValueKind == JsonValueKind.String) + { + return $"HTTP {(int)response.StatusCode}: {messageEl.GetString()}"; + } + } + catch (JsonException) + { + } + + body = body.Length <= 400 ? body : body[..400]; + return $"HTTP {(int)response.StatusCode}: {body}"; + } + public async Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30) { if (string.IsNullOrWhiteSpace(text)) return null; @@ -171,12 +200,12 @@ namespace JobTrackerApi.Services Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks); if (!res.IsSuccessStatusCode) { - var errorBody = await res.Content.ReadAsStringAsync(); + var errorBody = await ReadErrorBodyAsync(res); Interlocked.Increment(ref _failures); lock (_metricsLock) { _lastFailureAt = DateTimeOffset.UtcNow; - _lastError = $"AI summarize returned {(int)res.StatusCode}: {errorBody}"; + _lastError = $"AI summarize failed: {errorBody}"; } return null; } @@ -235,11 +264,12 @@ namespace JobTrackerApi.Services Interlocked.Add(ref _totalOcrLatencyTicks, sw.ElapsedTicks); if (!response.IsSuccessStatusCode) { + var errorBody = await ReadErrorBodyAsync(response, cancellationToken); Interlocked.Increment(ref _ocrFailures); lock (_metricsLock) { _lastOcrFailureAt = DateTimeOffset.UtcNow; - _lastError = $"AI extraction returned {(int)response.StatusCode}."; + _lastError = $"AI extraction failed: {errorBody}"; } return null; } @@ -296,11 +326,12 @@ namespace JobTrackerApi.Services if (!res.IsSuccessStatusCode) { + var errorBody = await ReadErrorBodyAsync(res, cancellationToken); Interlocked.Increment(ref _probeFailures); lock (_metricsLock) { _lastProbeFailureAt = DateTimeOffset.UtcNow; - _lastError = $"Probe returned {(int)res.StatusCode}."; + _lastError = $"AI probe failed: {errorBody}"; } return; } @@ -358,6 +389,8 @@ namespace JobTrackerApi.Services double? healthLatencyMs = null; var healthy = false; string? healthError = null; + bool? summarizeAvailable = null; + string? modelLoadError = null; try { @@ -377,6 +410,8 @@ namespace JobTrackerApi.Services if (doc.RootElement.TryGetProperty("gpu_name", out var gpuNameEl)) gpuName = gpuNameEl.GetString(); if (doc.RootElement.TryGetProperty("ocr_available", out var ocrAvailableEl) && ocrAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ocrAvailable = ocrAvailableEl.GetBoolean(); if (doc.RootElement.TryGetProperty("ocr_languages", out var ocrLanguagesEl)) ocrLanguages = ocrLanguagesEl.GetString(); + if (doc.RootElement.TryGetProperty("summarize_available", out var summarizeAvailableEl) && summarizeAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) summarizeAvailable = summarizeAvailableEl.GetBoolean(); + if (doc.RootElement.TryGetProperty("model_load_error", out var modelLoadErrorEl) && modelLoadErrorEl.ValueKind == JsonValueKind.String) modelLoadError = modelLoadErrorEl.GetString(); if (doc.RootElement.TryGetProperty("ollama_configured", out var ollamaConfiguredEl) && ollamaConfiguredEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaConfigured = ollamaConfiguredEl.GetBoolean(); if (doc.RootElement.TryGetProperty("ollama_reachable", out var ollamaReachableEl) && ollamaReachableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaReachable = ollamaReachableEl.GetBoolean(); if (doc.RootElement.TryGetProperty("ollama_model", out var ollamaModelEl)) ollamaModel = ollamaModelEl.GetString(); @@ -391,6 +426,13 @@ namespace JobTrackerApi.Services ollamaLoadedModels = ollamaLoadedModelsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList(); } if (doc.RootElement.TryGetProperty("ollama_loaded_count", out var ollamaLoadedCountEl) && ollamaLoadedCountEl.ValueKind == JsonValueKind.Number) ollamaLoadedCount = ollamaLoadedCountEl.GetInt32(); + if (summarizeAvailable == false) + { + healthy = false; + healthError = string.IsNullOrWhiteSpace(modelLoadError) + ? "AI summarize capability is unavailable." + : modelLoadError; + } } else { diff --git a/JobTrackerApi/appsettings.Development.json b/JobTrackerApi/appsettings.Development.json index 46c7cdf..0218ac6 100644 --- a/JobTrackerApi/appsettings.Development.json +++ b/JobTrackerApi/appsettings.Development.json @@ -8,6 +8,7 @@ "Cors": { "Origins": [ "http://localhost:3000", + "http://localhost:3001", "https://jobs.cesnimda.uk" ] }, diff --git a/Models/StructuredCvProfileJson.cs b/Models/StructuredCvProfileJson.cs index 77dc8da..da0abb3 100644 --- a/Models/StructuredCvProfileJson.cs +++ b/Models/StructuredCvProfileJson.cs @@ -670,10 +670,62 @@ public static class StructuredCvProfileJson } } - var leftovers = lines.Where(line => !line.Contains('@') && !line.Contains("linkedin", StringComparison.OrdinalIgnoreCase) && !line.Equals(contact.Website, StringComparison.OrdinalIgnoreCase) && !line.Equals(contact.Phone, StringComparison.OrdinalIgnoreCase)).ToList(); - if (leftovers.Count > 0) contact.FullName ??= leftovers[0].Trim(); - if (leftovers.Count > 1) contact.Headline ??= leftovers[1].Trim(); - if (leftovers.Count > 2) contact.Location ??= leftovers[2].Trim(); + var leftovers = lines.Where(line => !line.Contains('@') + && !line.Contains("linkedin", StringComparison.OrdinalIgnoreCase) + && !line.Equals(contact.Website, StringComparison.OrdinalIgnoreCase) + && !line.Equals(contact.Phone, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var plausibleName = leftovers.FirstOrDefault(line => LooksLikePersonName(line)); + contact.FullName ??= plausibleName?.Trim(); + contact.FullName ??= GuessNameFromLinkedIn(contact.LinkedIn); + contact.FullName ??= GuessNameFromEmail(contact.Email); + + var remaining = leftovers.Where(line => !string.Equals(line, contact.FullName, StringComparison.OrdinalIgnoreCase)).ToList(); + var addressLike = remaining.Where(LooksLikeAddressish).ToList(); + if (remaining.Count > 1 && !LooksLikeAddressish(remaining[0])) contact.Headline ??= remaining[0].Trim(); + contact.Location ??= addressLike.LastOrDefault()?.Trim(); + if (string.IsNullOrWhiteSpace(contact.Location)) + { + var nonHeadline = remaining.Where(line => !string.Equals(line, contact.Headline, StringComparison.OrdinalIgnoreCase)).ToList(); + contact.Location ??= nonHeadline.LastOrDefault()?.Trim(); + } + } + + private static bool LooksLikeAddressish(string value) + { + return value.Any(char.IsDigit) + || Regex.IsMatch(value, @"\b(street|st\.?|road|rd\.?|avenue|ave\.?|suite|city|london|new york|oslo|uk|ny)\b", RegexOptions.IgnoreCase); + } + + private static bool LooksLikePersonName(string value) + { + return Regex.IsMatch(value.Trim(), @"^[A-Z][A-Za-z'`.-]+(?:\s+[A-Z][A-Za-z'`.-]+){1,3}$"); + } + + private static string? GuessNameFromLinkedIn(string? linkedIn) + { + var value = TrimOrNull(linkedIn); + if (value is null) return null; + var match = Regex.Match(value, @"linkedin\.com/(?:in|pub)/(?<slug>[a-z0-9._-]+)", RegexOptions.IgnoreCase); + if (!match.Success) return null; + var parts = Regex.Split(match.Groups["slug"].Value, @"[._-]+") + .Where(part => !string.IsNullOrWhiteSpace(part) && part.All(ch => char.IsLetter(ch))) + .Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()) + .ToList(); + return parts.Count >= 2 ? string.Join(" ", parts) : null; + } + + private static string? GuessNameFromEmail(string? email) + { + if (string.IsNullOrWhiteSpace(email) || !email.Contains('@')) return null; + var local = email[..email.IndexOf('@')].Trim(); + if (string.IsNullOrWhiteSpace(local)) return null; + var parts = Regex.Split(local, @"[._-]+", RegexOptions.None) + .Where(part => !string.IsNullOrWhiteSpace(part)) + .Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()) + .ToList(); + return parts.Count >= 2 ? string.Join(" ", parts) : null; } private static List<StructuredCvLanguage> ParseLanguages(string content) @@ -681,15 +733,16 @@ public static class StructuredCvProfileJson return SplitList(content) .Select(item => { - var name = item; + var normalized = item.Trim(); + var name = normalized; string? level = null; string? notes = null; - var colonIndex = item.IndexOf(':'); + var colonIndex = normalized.IndexOf(':'); if (colonIndex > 0) { - name = item[..colonIndex].Trim(); - var remainder = item[(colonIndex + 1)..].Trim(); + name = normalized[..colonIndex].Trim(); + var remainder = normalized[(colonIndex + 1)..].Trim(); var noteMatch = Regex.Match(remainder, @"^(.*?)\s*\((.*?)\)$"); if (noteMatch.Success) { @@ -701,8 +754,26 @@ public static class StructuredCvProfileJson level = remainder.NullIfWhitespace(); } } + else + { + var dashMatch = Regex.Match(normalized, @"^(?<name>[\p{L}][\p{L}\s-]+?)\s*[–-]\s*(?<level>.+)$"); + if (dashMatch.Success) + { + name = dashMatch.Groups["name"].Value.Trim(); + level = dashMatch.Groups["level"].Value.Trim(); + } + else + { + var parenMatch = Regex.Match(normalized, @"^(?<name>[\p{L}][\p{L}\s-]+?)\s*\((?<level>.+)\)$"); + if (parenMatch.Success) + { + name = parenMatch.Groups["name"].Value.Trim(); + level = parenMatch.Groups["level"].Value.Trim(); + } + } + } - var normalizedLevel = HumanLanguageCatalog.ExtractLevel(level) ?? HumanLanguageCatalog.ExtractLevel(item); + var normalizedLevel = HumanLanguageCatalog.ExtractLevel(level) ?? HumanLanguageCatalog.ExtractLevel(normalized); return new StructuredCvLanguage { Name = normalizedLevel is not null ? HumanLanguageCatalog.NormalizeLanguageName(name) : null, @@ -729,11 +800,20 @@ public static class StructuredCvProfileJson if (lines[0].StartsWith("###", StringComparison.Ordinal)) lines[0] = lines[0].TrimStart('#', ' '); job.Title = lines[0].NullIfWhitespace(); - var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line)).ToList(); - var dateValue = metadata.Select(line => Regex.Match(line, @"(?:(?:\w+\s+)?\d{4}|Present|Current)(?:\s*[-–]\s*(?:(?:\w+\s+)?\d{4}|Present|Current))?", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null); - if (!string.IsNullOrWhiteSpace(dateValue)) + var titleDateMatch = Regex.Match(job.Title ?? string.Empty, @"(?<title>.+?)\s*[-–]\s*(?<start>(?:\d{1,2}/)?\d{4})\s*(?:to|[-–])\s*(?<end>(?:\d{1,2}/)?\d{4}|Present|Current)$", RegexOptions.IgnoreCase); + if (titleDateMatch.Success) { - var parts = Regex.Split(dateValue, "\\s*[-–]\\s*"); + job.Title = titleDateMatch.Groups["title"].Value.NullIfWhitespace(); + job.Start = titleDateMatch.Groups["start"].Value.NullIfWhitespace(); + job.End = titleDateMatch.Groups["end"].Value.NullIfWhitespace(); + job.IsCurrent = string.Equals(job.End, "present", StringComparison.OrdinalIgnoreCase) || string.Equals(job.End, "current", StringComparison.OrdinalIgnoreCase); + } + + var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line)).ToList(); + var dateValue = metadata.Select(line => Regex.Match(line, @"(?:(?:\d{1,2}/)?\d{4}|Present|Current)(?:\s*(?:[-–]|to)\s*(?:(?:\d{1,2}/)?\d{4}|Present|Current))?", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null); + if (!string.IsNullOrWhiteSpace(dateValue) && string.IsNullOrWhiteSpace(job.Start)) + { + var parts = Regex.Split(dateValue, "\\s*(?:[-–]|to)\\s*"); job.Start = parts.FirstOrDefault().NullIfWhitespace(); job.End = parts.Skip(1).FirstOrDefault().NullIfWhitespace(); job.IsCurrent = string.Equals(job.End, "present", StringComparison.OrdinalIgnoreCase) || string.Equals(job.End, "current", StringComparison.OrdinalIgnoreCase); @@ -752,10 +832,32 @@ public static class StructuredCvProfileJson .Where(line => line.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase)) .SelectMany(line => SplitList(line[(line.IndexOf(':') + 1)..])) .ToList(); + if (job.Skills.Count == 0) + { + job.Skills = job.Bullets + .SelectMany(ExtractSkillsFromBullet) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } return string.IsNullOrWhiteSpace(job.Title) && string.IsNullOrWhiteSpace(job.Company) && job.Bullets.Count == 0 ? null : job; } + private static IEnumerable<string> ExtractSkillsFromBullet(string bullet) + { + if (string.IsNullOrWhiteSpace(bullet)) yield break; + + var usingMatch = Regex.Match(bullet, @"\b(?:using|including|with|technologies?:|tools?:)\s+(?<skills>.+)$", RegexOptions.IgnoreCase); + if (usingMatch.Success) + { + foreach (var item in SplitList(usingMatch.Groups["skills"].Value)) + { + var trimmed = item.Trim().TrimEnd('.'); + if (trimmed.Length >= 2 && trimmed.Length <= 40) yield return trimmed; + } + } + } + private static List<StructuredCvEducation> ParseEducation(string content) { var blocks = SplitBlocks(content); diff --git a/job-tracker-ui/package-lock.json b/job-tracker-ui/package-lock.json index 40bc5e5..40c78e9 100644 --- a/job-tracker-ui/package-lock.json +++ b/job-tracker-ui/package-lock.json @@ -24,7 +24,7 @@ "@types/node": "^16.18.126", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "axios": "^1.13.6", + "axios": "^1.15.0", "date-fns": "^4.1.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -5492,14 +5492,14 @@ } }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/axobject-query": { @@ -14233,10 +14233,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/psl": { "version": "1.15.0", @@ -16428,8 +16431,9 @@ "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "extraneous": true, "license": "ISC", + "optional": true, + "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/job-tracker-ui/package.json b/job-tracker-ui/package.json index e99ed4f..6b9b143 100644 --- a/job-tracker-ui/package.json +++ b/job-tracker-ui/package.json @@ -19,7 +19,7 @@ "@types/node": "^16.18.126", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "axios": "^1.13.6", + "axios": "^1.15.0", "date-fns": "^4.1.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 158104c..5f5cd27 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -32,7 +32,7 @@ import ForgotPasswordPage from "./pages/ForgotPasswordPage"; import ResetPasswordPage from "./pages/ResetPasswordPage"; import RouteErrorPage from "./pages/RouteErrorPage"; import { api } from "./api"; -import { clearAuthToken, getAuthToken } from "./auth"; +import { clearAuthClientState, setAuthUserKey } from "./auth"; import AppShell, { NavItem } from "./layout/AppShell"; import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs"; @@ -112,6 +112,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo const [quickOpen, setQuickOpen] = useState(false); const [refreshToken, setRefreshToken] = useState(0); const [requireAuth, setRequireAuth] = useState<boolean | null>(null); + const [authResolved, setAuthResolved] = useState(false); const [isAdmin, setIsAdmin] = useState(false); const [me, setMe] = useState<MeResponse | null>(null); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); @@ -124,7 +125,26 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false)); }, []); useEffect(() => { - api.get<MeResponse>("/auth/me").then((r) => { setMe(r.data); setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))); }).catch(() => { setMe(null); setIsAdmin(false); }); + let active = true; + api.get<MeResponse>("/auth/me") + .then((r) => { + if (!active) return; + setMe(r.data); + setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))); + setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false); + }) + .catch(() => { + if (!active) return; + setMe(null); + setIsAdmin(false); + clearAuthClientState(false); + }) + .finally(() => { + if (active) setAuthResolved(true); + }); + return () => { + active = false; + }; }, []); useEffect(() => { const load = () => { @@ -134,6 +154,27 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo const id = window.setInterval(load, 60000); return () => window.clearInterval(id); }, []); + useEffect(() => { + const onAuthChanged = () => { + setAuthResolved(false); + api.get<MeResponse>("/auth/me") + .then((r) => { + setMe(r.data); + setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))); + setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false); + }) + .catch(() => { + setMe(null); + setIsAdmin(false); + clearAuthClientState(false); + }) + .finally(() => setAuthResolved(true)); + }; + + window.addEventListener("auth-changed", onAuthChanged); + return () => window.removeEventListener("auth-changed", onAuthChanged); + }, []); + useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { @@ -145,9 +186,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo return () => window.removeEventListener("keydown", onKeyDown); }, []); - const token = getAuthToken(); - if (requireAuth === null) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>; - if (requireAuth && !token) return <Navigate to="/login" replace state={{ from: path }} />; + if (requireAuth === null || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>; + if (requireAuth && !me) return <Navigate to="/login" replace state={{ from: path }} />; const pageTitle = titleFor(path, t); const breadcrumbs = breadcrumbsFor(path, t); @@ -223,7 +263,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo onOpenNotifications={() => navigate("/reminders")} onOpenSettings={() => navigate("/settings")} onOpenProfile={() => navigate("/profile")} - onSignOut={() => { clearAuthToken(); navigate("/login"); }} + onSignOut={() => { void api.post("/auth/logout").catch(() => undefined).finally(() => { clearAuthClientState(); navigate("/login"); }); }} rightActions={rightActions} > <Suspense fallback={<PageLoader />}> diff --git a/job-tracker-ui/src/api.ts b/job-tracker-ui/src/api.ts index 4e3a6fb..4bdc6d8 100644 --- a/job-tracker-ui/src/api.ts +++ b/job-tracker-ui/src/api.ts @@ -1,6 +1,5 @@ import axios from "axios"; -import { getAuthToken } from "./auth"; -import { clearAuthToken } from "./auth"; +import { clearAuthClientState, getCsrfToken } from "./auth"; export function getApiErrorMessage(error: any, fallback = "Request failed.") { const data = error?.response?.data; @@ -33,13 +32,19 @@ const defaultBaseUrl = export const api = axios.create({ baseURL: envBaseUrl && envBaseUrl.trim().length > 0 ? envBaseUrl : defaultBaseUrl, + withCredentials: true, + xsrfCookieName: "XSRF-TOKEN", + xsrfHeaderName: "X-CSRF-TOKEN", }); api.interceptors.request.use((config) => { - const token = getAuthToken(); - if (token) { - config.headers = config.headers ?? {}; - config.headers.Authorization = `Bearer ${token}`; + const method = (config.method ?? "get").toUpperCase(); + if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) { + const csrfToken = getCsrfToken(); + if (csrfToken) { + config.headers = config.headers ?? {}; + config.headers["X-CSRF-TOKEN"] = csrfToken; + } } return config; }); @@ -47,12 +52,9 @@ api.interceptors.request.use((config) => { api.interceptors.response.use( (r) => r, (err) => { - // If tokens expire (Google ID tokens are short-lived), clear and let the UI prompt the user. const status = err?.response?.status; if (status === 401) { - clearAuthToken(); - // Avoid hard navigation loops; let views handle the missing token state. - // We still reject so callers can show a toast if they want. + clearAuthClientState(); } return Promise.reject(err); }, diff --git a/job-tracker-ui/src/auth.ts b/job-tracker-ui/src/auth.ts index a625b62..1269ec5 100644 --- a/job-tracker-ui/src/auth.ts +++ b/job-tracker-ui/src/auth.ts @@ -1,13 +1,9 @@ -export const AUTH_TOKEN_KEY = "authToken"; export const AUTH_REMEMBER_ME_KEY = "authRememberMe"; -const LEGACY_AUTH_TOKEN_KEY = "googleIdToken"; -const AUTH_TOKEN_PERSISTENCE_KEY = "authTokenPersistence"; +const AUTH_PERSISTENCE_KEY = "authTokenPersistence"; +const AUTH_USER_KEY = "authUserKey"; +const AUTH_CSRF_COOKIE = "XSRF-TOKEN"; -type AuthPersistence = "local" | "session"; - -function normalizePersistence(value: string | null | undefined): AuthPersistence { - return value === "session" ? "session" : "local"; -} +export type AuthPersistence = "local" | "session"; function safeGet(storage: Storage, key: string): string | null { try { @@ -33,28 +29,27 @@ function safeRemove(storage: Storage, key: string) { } } +function emitAuthChanged() { + window.dispatchEvent(new Event("auth-changed")); +} + +function normalizePersistence(value: string | null | undefined): AuthPersistence { + return value === "session" ? "session" : "local"; +} + function persistPreference(persistence: AuthPersistence) { - safeSet(window.localStorage, AUTH_TOKEN_PERSISTENCE_KEY, persistence); + safeSet(window.localStorage, AUTH_PERSISTENCE_KEY, persistence); safeSet(window.localStorage, AUTH_REMEMBER_ME_KEY, persistence === "local" ? "1" : "0"); } function getStoredPersistence(): AuthPersistence { - const explicit = safeGet(window.localStorage, AUTH_TOKEN_PERSISTENCE_KEY); + const explicit = safeGet(window.localStorage, AUTH_PERSISTENCE_KEY); if (explicit) return normalizePersistence(explicit); const rememberMe = safeGet(window.localStorage, AUTH_REMEMBER_ME_KEY); if (rememberMe === "0") return "session"; return "local"; } -function migrateLegacyToken(): string | null { - const legacy = safeGet(window.localStorage, LEGACY_AUTH_TOKEN_KEY) ?? safeGet(window.sessionStorage, LEGACY_AUTH_TOKEN_KEY); - if (!legacy) return null; - safeRemove(window.localStorage, LEGACY_AUTH_TOKEN_KEY); - safeRemove(window.sessionStorage, LEGACY_AUTH_TOKEN_KEY); - setAuthToken(legacy, getStoredPersistence()); - return legacy; -} - export function getRememberMePref(): boolean { return getAuthPersistencePreference() === "local"; } @@ -63,53 +58,40 @@ export function setRememberMePref(value: boolean) { persistPreference(value ? "local" : "session"); } -export function getAuthToken(): string | null { - const localToken = safeGet(window.localStorage, AUTH_TOKEN_KEY); - if (localToken) { - persistPreference("local"); - return localToken; - } - - const sessionToken = safeGet(window.sessionStorage, AUTH_TOKEN_KEY); - if (sessionToken) { - persistPreference("session"); - return sessionToken; - } - - return migrateLegacyToken(); -} - export function getAuthPersistencePreference(): AuthPersistence { - if (safeGet(window.sessionStorage, AUTH_TOKEN_KEY)) return "session"; - if (safeGet(window.localStorage, AUTH_TOKEN_KEY)) return "local"; return getStoredPersistence(); } -export function setAuthToken(token: string, persistence: AuthPersistence = "local") { - safeRemove(window.localStorage, AUTH_TOKEN_KEY); - safeRemove(window.sessionStorage, AUTH_TOKEN_KEY); - - if (persistence === "session") { - safeSet(window.sessionStorage, AUTH_TOKEN_KEY, token); - } else { - safeSet(window.localStorage, AUTH_TOKEN_KEY, token); - } - +export function setAuthPersistencePreference(persistence: AuthPersistence) { persistPreference(persistence); + emitAuthChanged(); } -export function clearAuthToken() { - safeRemove(window.localStorage, AUTH_TOKEN_KEY); - safeRemove(window.sessionStorage, AUTH_TOKEN_KEY); +export function getAuthUserKey(): string { + return safeGet(window.localStorage, AUTH_USER_KEY) ?? "anon"; } -export function decodeJwtPayload(token: string): any { +export function setAuthUserKey(value: string | null | undefined, emit = true) { + const next = typeof value === "string" ? value.trim() : ""; + if (!next) { + safeRemove(window.localStorage, AUTH_USER_KEY); + } else { + safeSet(window.localStorage, AUTH_USER_KEY, next); + } + if (emit) emitAuthChanged(); +} + +export function clearAuthClientState(emit = true) { + safeRemove(window.localStorage, AUTH_USER_KEY); + if (emit) emitAuthChanged(); +} + +export function getCsrfToken(): string | null { try { - const parts = token.split("."); - if (parts.length < 2) return null; - const base64 = parts[1].replaceAll("-", "+").replaceAll("_", "/"); - const json = atob(base64); - return JSON.parse(json); + const parts = document.cookie.split(";").map((part) => part.trim()); + const match = parts.find((part) => part.startsWith(`${AUTH_CSRF_COOKIE}=`)); + if (!match) return null; + return decodeURIComponent(match.slice(AUTH_CSRF_COOKIE.length + 1)); } catch { return null; } diff --git a/job-tracker-ui/src/components/AuthStatusCard.tsx b/job-tracker-ui/src/components/AuthStatusCard.tsx index 0bdddcf..e7dd446 100644 --- a/job-tracker-ui/src/components/AuthStatusCard.tsx +++ b/job-tracker-ui/src/components/AuthStatusCard.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { Box, Button, Paper, Typography } from "@mui/material"; import { api } from "../api"; -import { clearAuthToken, getAuthToken } from "../auth"; +import { clearAuthClientState } from "../auth"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; @@ -25,19 +25,20 @@ type MeResponse = { export default function AuthStatusCard() { const { toast } = useToast(); const { t } = useI18n(); - const token = getAuthToken(); const [me, setMe] = useState<MeResponse | null>(null); useEffect(() => { - if (!token) { - setMe(null); - return; - } - api - .get<MeResponse>("/auth/me") - .then((r) => setMe(r.data)) - .catch(() => setMe(null)); - }, [token]); + const refresh = () => { + api + .get<MeResponse>("/auth/me") + .then((r) => setMe(r.data)) + .catch(() => setMe(null)); + }; + + refresh(); + window.addEventListener("auth-changed", refresh); + return () => window.removeEventListener("auth-changed", refresh); + }, []); const label = useMemo(() => me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email, [me]); @@ -47,7 +48,7 @@ export default function AuthStatusCard() { {t("authStatusTitle")} </Typography> - {!token ? ( + {!me ? ( <Typography sx={{ color: "text.secondary" }}>{t("authStatusNotSignedIn")}</Typography> ) : ( <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}> @@ -69,9 +70,11 @@ export default function AuthStatusCard() { <Button variant="outlined" onClick={() => { - clearAuthToken(); - setMe(null); - toast(t("signedOut"), "info"); + void api.post("/auth/logout").catch(() => undefined).finally(() => { + setMe(null); + clearAuthClientState(); + toast(t("signedOut"), "info"); + }); }} > {t("signOut")} diff --git a/job-tracker-ui/src/components/CompaniesTable.tsx b/job-tracker-ui/src/components/CompaniesTable.tsx index 618a70f..08c17c4 100644 --- a/job-tracker-ui/src/components/CompaniesTable.tsx +++ b/job-tracker-ui/src/components/CompaniesTable.tsx @@ -23,10 +23,12 @@ import { import useMediaQuery from "@mui/material/useMediaQuery"; import { api, getApiErrorMessage } from "../api"; +import ViewStateNotice from "./ViewStateNotice"; import { Company } from "../types"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; +import { useViewResource } from "../hooks/useViewResource"; export default function CompaniesTable() { const isMobile = useMediaQuery("(max-width:767.95px)"); @@ -34,7 +36,6 @@ export default function CompaniesTable() { const { t } = useI18n(); const location = useLocation(); const navigate = useNavigate(); - const [companies, setCompanies] = useState<Company[]>([]); const [editOpen, setEditOpen] = useState(false); const [editing, setEditing] = useState<Company | null>(null); @@ -45,9 +46,19 @@ export default function CompaniesTable() { const [lastContactedAt, setLastContactedAt] = useState(""); const [nextContactAt, setNextContactAt] = useState(""); - useEffect(() => { - api.get<Company[]>("/companies").then((r) => setCompanies(r.data)).catch((error) => toast(getApiErrorMessage(error, t("companiesUpdateFailed")), "error")); - }, [t, toast]); + const companiesResource = useViewResource( + async () => { + const response = await api.get<Company[]>("/companies"); + return response.data; + }, + { + initialData: [], + errorMessage: t("companiesUpdateFailed"), + deps: [t], + }, + ); + + const companies = companiesResource.data; useEffect(() => { const params = new URLSearchParams(location.search); @@ -88,7 +99,7 @@ export default function CompaniesTable() { nextContactAt: nextContactAt || null, }); - setCompanies((prev) => prev.map((x) => (x.id === res.data.id ? res.data : x))); + companiesResource.setData((prev) => prev.map((x) => (x.id === res.data.id ? res.data : x))); toast(t("companiesUpdated"), "success"); setEditOpen(false); setEditing(null); @@ -106,82 +117,92 @@ export default function CompaniesTable() { return ( <Paper sx={{ mt: 0, p: { xs: 1.5, sm: 0 } }}> - {isMobile ? ( - <Stack spacing={1.5}> - {companies.map((c) => ( - <Paper key={c.id} sx={{ p: 1.5, borderRadius: 3 }}> - <Stack spacing={1.25}> - <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}> - <Box> - <Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{c.name}</Typography> - <Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{c.location || t("companiesLocation")}</Typography> - </Box> - <IconButton size="small" onClick={() => openEdit(c)}> - <EditOutlinedIcon fontSize="small" /> - </IconButton> - </Box> + <ViewStateNotice + loading={companiesResource.loading} + error={companiesResource.error} + title="Unable to load companies" + description="The companies list is unavailable right now. Try again when the API is reachable." + onRetry={companiesResource.reload} + /> - <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}> - {renderCompanyMeta(t("companiesSource"), c.source)} - {renderCompanyMeta(t("companiesPipeline"), c.pipelineStage)} - {renderCompanyMeta(t("companiesRecruiter"), [c.recruiterName, c.recruiterEmail].filter(Boolean).join(" · "))} - {renderCompanyMeta(t("companiesNextContact"), c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : null)} - </Box> - </Stack> - </Paper> - ))} - {companies.length === 0 ? ( - <Typography sx={{ py: 2, textAlign: "center" }}> - {t("companiesEmpty")} - </Typography> - ) : null} - </Stack> - ) : ( - <TableContainer sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider" }}> - <Table> - <TableHead> - <TableRow> - <TableCell>{t("companiesName")}</TableCell> - <TableCell>{t("companiesLocation")}</TableCell> - <TableCell>{t("companiesSource")}</TableCell> - <TableCell>{t("companiesPipeline")}</TableCell> - <TableCell>{t("companiesRecruiter")}</TableCell> - <TableCell>{t("companiesNextContact")}</TableCell> - <TableCell width={1} align="right" /> - </TableRow> - </TableHead> - <TableBody> - {companies.map((c) => ( - <TableRow key={c.id}> - <TableCell>{c.name}</TableCell> - <TableCell>{c.location ?? ""}</TableCell> - <TableCell>{c.source ?? ""}</TableCell> - <TableCell>{c.pipelineStage ?? ""}</TableCell> - <TableCell> - {c.recruiterName ?? ""} - {c.recruiterEmail ? ` (${c.recruiterEmail})` : ""} - </TableCell> - <TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell> - <TableCell align="right"> + {!companiesResource.loading && !companiesResource.error ? ( + isMobile ? ( + <Stack spacing={1.5}> + {companies.map((c) => ( + <Paper key={c.id} sx={{ p: 1.5, borderRadius: 3 }}> + <Stack spacing={1.25}> + <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}> + <Box> + <Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{c.name}</Typography> + <Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{c.location || t("companiesLocation")}</Typography> + </Box> <IconButton size="small" onClick={() => openEdit(c)}> <EditOutlinedIcon fontSize="small" /> </IconButton> - </TableCell> - </TableRow> - ))} - {companies.length === 0 && ( + </Box> + + <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}> + {renderCompanyMeta(t("companiesSource"), c.source)} + {renderCompanyMeta(t("companiesPipeline"), c.pipelineStage)} + {renderCompanyMeta(t("companiesRecruiter"), [c.recruiterName, c.recruiterEmail].filter(Boolean).join(" · "))} + {renderCompanyMeta(t("companiesNextContact"), c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : null)} + </Box> + </Stack> + </Paper> + ))} + {companies.length === 0 ? ( + <Typography sx={{ py: 2, textAlign: "center" }}> + {t("companiesEmpty")} + </Typography> + ) : null} + </Stack> + ) : ( + <TableContainer sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider" }}> + <Table> + <TableHead> <TableRow> - <TableCell colSpan={7}> - <Typography sx={{ py: 2, textAlign: "center" }}> - {t("companiesEmpty")} - </Typography> - </TableCell> + <TableCell>{t("companiesName")}</TableCell> + <TableCell>{t("companiesLocation")}</TableCell> + <TableCell>{t("companiesSource")}</TableCell> + <TableCell>{t("companiesPipeline")}</TableCell> + <TableCell>{t("companiesRecruiter")}</TableCell> + <TableCell>{t("companiesNextContact")}</TableCell> + <TableCell width={1} align="right" /> </TableRow> - )} - </TableBody> - </Table> - </TableContainer> - )} + </TableHead> + <TableBody> + {companies.map((c) => ( + <TableRow key={c.id}> + <TableCell>{c.name}</TableCell> + <TableCell>{c.location ?? ""}</TableCell> + <TableCell>{c.source ?? ""}</TableCell> + <TableCell>{c.pipelineStage ?? ""}</TableCell> + <TableCell> + {c.recruiterName ?? ""} + {c.recruiterEmail ? ` (${c.recruiterEmail})` : ""} + </TableCell> + <TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell> + <TableCell align="right"> + <IconButton size="small" onClick={() => openEdit(c)}> + <EditOutlinedIcon fontSize="small" /> + </IconButton> + </TableCell> + </TableRow> + ))} + {companies.length === 0 && ( + <TableRow> + <TableCell colSpan={7}> + <Typography sx={{ py: 2, textAlign: "center" }}> + {t("companiesEmpty")} + </Typography> + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </TableContainer> + ) + ) : null} <Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth fullScreen={isMobile} maxWidth="sm"> <DialogTitle>{t("companiesEdit")}</DialogTitle> diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index 371dcf9..e59c5a8 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; import { @@ -22,10 +22,12 @@ import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined"; import AutoGraphIcon from "@mui/icons-material/AutoGraph"; import { api } from "../api"; +import ViewStateNotice from "./ViewStateNotice"; import { getUserKeyFromToken } from "../themePrefs"; import { useI18n } from "../i18n/I18nProvider"; import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals"; import { JobApplication } from "../types"; +import { useViewResource } from "../hooks/useViewResource"; interface JobStats { total: number; @@ -130,28 +132,58 @@ export default function DashboardView() { const isMobile = useMediaQuery("(max-width:767.95px)"); const navigate = useNavigate(); const { t } = useI18n(); - const [stats, setStats] = useState<JobStats | null>(null); - const [overview, setOverview] = useState<OverviewAnalytics | null>(null); - const [tagTrends, setTagTrends] = useState<TagTrendResponse | null>(null); - const [analytics, setAnalytics] = useState<AnalyticsPoint[]>([]); - const [tags, setTags] = useState<TagPoint[]>([]); const [months, setMonths] = useState<6 | 12 | 24>(12); - const [reminderJobs, setReminderJobs] = useState<ReminderJob[]>([]); const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs()); const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null); + const summaryResource = useViewResource( + async () => { + const [statsResponse, overviewResponse, remindersResponse] = await Promise.all([ + api.get<JobStats>("/jobapplications/stats"), + api.get<OverviewAnalytics>("/jobapplications/analytics-overview"), + api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }), + ]); - useEffect(() => { - api.get<JobStats>("/jobapplications/stats").then((r) => setStats(r.data)); - api.get<OverviewAnalytics>("/jobapplications/analytics-overview").then((r) => setOverview(r.data)).catch(() => setOverview(null)); - api.get<ReminderJob[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }).then((r) => setReminderJobs(Array.isArray(r.data) ? r.data : [])).catch(() => setReminderJobs([])); - }, []); + return { + stats: statsResponse.data, + overview: overviewResponse.data, + reminderJobs: Array.isArray(remindersResponse.data) ? remindersResponse.data : [], + }; + }, + { + initialData: { stats: null as JobStats | null, overview: null as OverviewAnalytics | null, reminderJobs: [] as ReminderJob[] }, + errorMessage: "Unable to load dashboard summary data right now.", + deps: [], + }, + ); - useEffect(() => { - const params = { months }; - api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }).then((r) => setAnalytics(r.data ?? [])).catch(() => setAnalytics([])); - api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }).then((r) => setTags(r.data ?? [])).catch(() => setTags([])); - api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null)); - }, [months]); + const trendsResource = useViewResource( + async () => { + const params = { months }; + const [analyticsResponse, tagsResponse, trendsResponse] = await Promise.all([ + api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }), + api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }), + api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }), + ]); + + return { + analytics: analyticsResponse.data ?? [], + tags: tagsResponse.data ?? [], + tagTrends: trendsResponse.data, + }; + }, + { + initialData: { analytics: [] as AnalyticsPoint[], tags: [] as TagPoint[], tagTrends: null as TagTrendResponse | null }, + errorMessage: "Unable to load dashboard trends right now.", + deps: [months], + }, + ); + + const stats = summaryResource.data.stats; + const overview = summaryResource.data.overview; + const reminderJobs = summaryResource.data.reminderJobs; + const analytics = trendsResource.data.analytics; + const tags = trendsResource.data.tags; + const tagTrends = trendsResource.data.tagTrends; const appliedValues = analytics.map((x) => x.applied); const responseValues = analytics.map((x) => x.responses); @@ -299,7 +331,23 @@ export default function DashboardView() { </Box> </SectionCard> - {prefs.cards ? ( + <ViewStateNotice + loading={summaryResource.loading} + error={summaryResource.error} + title="Unable to load dashboard summary" + description="The dashboard summary is unavailable right now." + onRetry={summaryResource.reload} + /> + <ViewStateNotice + loading={trendsResource.loading} + error={trendsResource.error} + title="Unable to load dashboard trends" + description="Charts and trend panels could not reach the API." + onRetry={trendsResource.reload} + compact + /> + + {!summaryResource.loading && !summaryResource.error && prefs.cards ? ( <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, 1fr)", xl: "repeat(4, 1fr)" }, gap: 2, mt: 2 }}> {metricCards.map((card) => ( <SectionCard key={card.label}> @@ -322,7 +370,7 @@ export default function DashboardView() { ) : null} <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "minmax(0, 1.8fr) minmax(320px, 0.9fr)" }, gap: 2, mt: 2 }}> - {prefs.activity ? ( + {!summaryResource.loading && !summaryResource.error && prefs.activity ? ( <SectionCard> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}> <Box> @@ -364,7 +412,8 @@ export default function DashboardView() { </SectionCard> ) : null} - <SectionCard> + {!summaryResource.loading && !summaryResource.error ? ( + <SectionCard> <Typography variant="h6" sx={{ fontWeight: 950 }}>{t("dashboardConversionFunnelTitle")}</Typography> <Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("dashboardResponseSources")}</Typography> <Stack spacing={1.2}> @@ -402,9 +451,11 @@ export default function DashboardView() { </Typography> </Box> </SectionCard> + ) : null} </Box> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}> + {!summaryResource.loading && !summaryResource.error ? ( <SectionCard> <Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("remindersTitle")}</Typography> <Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("remindersSubtitle")}</Typography> @@ -432,8 +483,9 @@ export default function DashboardView() { <Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button> </Box> </SectionCard> + ) : null} - {prefs.companies ? ( + {!summaryResource.loading && !summaryResource.error && prefs.companies ? ( <SectionCard> <Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography> <Stack spacing={1.25}> @@ -452,7 +504,7 @@ export default function DashboardView() { </SectionCard> ) : null} - {prefs.skills ? ( + {!trendsResource.loading && !trendsResource.error && prefs.skills ? ( <SectionCard> <Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopSkills")}</Typography> {tags.length === 0 ? ( diff --git a/job-tracker-ui/src/components/GoogleAuthCard.tsx b/job-tracker-ui/src/components/GoogleAuthCard.tsx index 249ca86..fc5b62b 100644 --- a/job-tracker-ui/src/components/GoogleAuthCard.tsx +++ b/job-tracker-ui/src/components/GoogleAuthCard.tsx @@ -1,9 +1,9 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Box, Button, Chip, Paper, Typography } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; -import { clearAuthToken, decodeJwtPayload, getAuthPersistencePreference, getAuthToken, setAuthToken } from "../auth"; +import { clearAuthClientState, getAuthPersistencePreference } from "../auth"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; @@ -49,26 +49,19 @@ function loadGoogleScript(): Promise<void> { export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) { const { toast } = useToast(); const { t } = useI18n(); - const [token, setToken] = useState<string | null>(() => getAuthToken()); const [me, setMe] = useState<MeResponse | null>(null); const [working, setWorking] = useState(false); const hostRef = useRef<HTMLDivElement | null>(null); const clientId = (process.env.REACT_APP_GOOGLE_CLIENT_ID || "").trim(); - const payload = useMemo(() => (token ? decodeJwtPayload(token) : null), [token]); - const isRawGoogleToken = payload?.iss === "accounts.google.com" || payload?.iss === "https://accounts.google.com"; - - const actionLabel = !token + const signedIn = Boolean(me?.provider); + const actionLabel = !signedIn ? t("continueWithGoogle") : me?.provider === "local" && !me?.googleLink?.linked ? t("linkWithGoogle") : t("signInWithGoogle"); async function refreshMe() { - if (!getAuthToken()) { - setMe(null); - return; - } try { const res = await api.get<MeResponse>("/auth/me"); setMe(res.data); @@ -79,37 +72,19 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void useEffect(() => { void refreshMe(); - }, [token]); + }, []); useEffect(() => { - if (!token || !isRawGoogleToken) return; - let cancelled = false; - const exchange = async () => { - try { - const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token }); - if (cancelled) return; - setAuthToken(res.data.accessToken, getAuthPersistencePreference()); - setToken(res.data.accessToken); - toast(t("googleSignedIn"), "success"); - onSignedIn?.(); - } catch { - if (cancelled) return; - clearAuthToken(); - setToken(null); - toast(t("googleNotLinkedYet"), "info"); - } - }; - void exchange(); - return () => { - cancelled = true; - }; - }, [token, isRawGoogleToken, onSignedIn, toast, t]); + const onAuthChanged = () => { void refreshMe(); }; + window.addEventListener("auth-changed", onAuthChanged); + return () => window.removeEventListener("auth-changed", onAuthChanged); + }, []); useEffect(() => { const host = hostRef.current; if (!clientId || !host) return; - const shouldRenderButton = !token || isRawGoogleToken || (me?.provider === "local" && !me?.googleLink?.linked); + const shouldRenderButton = !signedIn || (me?.provider === "local" && !me?.googleLink?.linked); host.replaceChildren(); if (!shouldRenderButton) return; @@ -126,13 +101,12 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void setWorking(true); try { if (me?.provider === "local") { - const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential }); + const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential, rememberMe: getAuthPersistencePreference() === "local" }); toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success"); await refreshMe(); } else { - const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token: credential }); - setAuthToken(res.data.accessToken, getAuthPersistencePreference()); - setToken(res.data.accessToken); + await api.post("/auth/google/exchange", { token: credential, rememberMe: getAuthPersistencePreference() === "local" }); + window.dispatchEvent(new Event("auth-changed")); toast(t("googleSignedIn"), "success"); onSignedIn?.(); } @@ -157,7 +131,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void active = false; host.replaceChildren(); }; - }, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast, t]); + }, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, signedIn, toast, t]); const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || ""; @@ -180,7 +154,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void {me?.googleLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("googleLinkedDate", { date: new Date(me.googleLink.linkedAt).toLocaleDateString() })} /> : null} </Box> - {!token ? ( + {!signedIn ? ( <Typography sx={{ color: "text.secondary" }}> {t("googleSignInHint")} </Typography> @@ -204,14 +178,15 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void </Box> <Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}> - {token ? ( + {signedIn ? ( <Button variant="outlined" onClick={() => { - clearAuthToken(); - setToken(null); - setMe(null); - toast(t("signedOut"), "info"); + void api.post("/auth/logout").catch(() => undefined).finally(() => { + clearAuthClientState(); + setMe(null); + toast(t("signedOut"), "info"); + }); }} > {t("signOut")} @@ -239,7 +214,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void ) : null} </Box> - {token && me?.email ? ( + {signedIn && me?.email ? ( <Typography variant="body2" sx={{ color: "text.secondary" }}> {t("signedInAs", { name: signedInName })} </Typography> diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 28ca08b..862ccbc 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -41,6 +41,7 @@ import ViewColumnIcon from "@mui/icons-material/ViewColumn"; import SearchIcon from "@mui/icons-material/Search"; import { api } from "../api"; +import ViewStateNotice from "./ViewStateNotice"; import { useCompanies } from "../hooks/useCompanies"; import { useDebouncedValue } from "../hooks/useDebouncedValue"; import JobDetailsDialog from "./JobDetailsDialog"; @@ -50,6 +51,7 @@ import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu"; import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; import { JobApplication } from "../types"; +import { useViewResource } from "../hooks/useViewResource"; import { getWorkflowAction, needsInterviewPrep, needsWorkflowWork } from "../jobWorkflowSignals"; interface PagedResult<T> { @@ -127,7 +129,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col const debouncedLocation = useDebouncedValue(locationFilter, 250); const [needsFollowUpOnly, setNeedsFollowUpOnly] = useState(false); const [readinessFilter, setReadinessFilter] = useState<"all" | "needs-work" | "interview">("all"); - const { companies } = useCompanies(); + const { companies, error: companiesError, reload: reloadCompanies } = useCompanies(); const [companyFilterId, setCompanyFilterId] = useState<number | "All">("All"); const [detailsJobId, setDetailsJobId] = useState<number | null>(null); const [detailsInitialTab, setDetailsInitialTab] = useState(0); @@ -153,13 +155,25 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col needsFollowUp: needsFollowUpOnly ? true : undefined, }), [page, pageSize, debouncedSearch, statusFilter, companyFilterId, debouncedLocation, includeDeleted, mode, sortBy, sortDir, needsFollowUpOnly]); + const jobsResource = useViewResource( + async () => { + const r = await api.get<PagedResult<JobApplication>>("/jobapplications", { params }); + return r.data; + }, + { + initialData: { items: [], total: 0, page: 1, pageSize }, + errorMessage: "Unable to load jobs right now.", + deps: [params, refreshToken, reloadToken, pageSize], + }, + ); + useEffect(() => { - api.get<PagedResult<JobApplication>>("/jobapplications", { params }).then((r) => { - setJobs(r.data.items); - setTotal(r.data.total); + setJobs(jobsResource.data.items); + setTotal(jobsResource.data.total); + if (!jobsResource.error) { setSelectedIds([]); - }); - }, [params, refreshToken, reloadToken]); + } + }, [jobsResource.data, jobsResource.error]); useEffect(() => { const paramsSearch = new URLSearchParams(location.search); @@ -460,6 +474,22 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ))} </Menu> + <ViewStateNotice + error={jobsResource.error} + title={mode === "trash" ? "Unable to load trash" : "Unable to load jobs"} + description={mode === "trash" ? "The deleted-jobs view cannot reach the API right now." : "The jobs list cannot reach the API right now."} + onRetry={jobsResource.reload} + /> + {companiesError ? ( + <ViewStateNotice + error={companiesError} + title="Unable to load company filters" + description="Company filter data is unavailable right now." + onRetry={reloadCompanies} + compact + /> + ) : null} + <Paper sx={{ mt: 2, overflow: "hidden" }}> {isMobile ? ( <Stack spacing={1.25} sx={{ p: 1.25 }}> @@ -467,7 +497,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col <FormControlLabel control={<Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} /> </Box> - {filteredJobs.map((job) => { + {jobsResource.loading ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography> : null} + {!jobsResource.loading && !jobsResource.error && filteredJobs.map((job) => { const toneName = statusTone(job.status); const primaryAction = getPrimaryAction(job); const actionSignals = getActionSignals(job); @@ -596,7 +627,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col </Paper> ); })} - {filteredJobs.length === 0 ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null} + {filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null} </Stack> ) : ( <Box sx={{ overflowX: "auto" }}> @@ -615,7 +646,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col </TableRow> </TableHead> <TableBody> - {filteredJobs.map((job) => { + {jobsResource.loading ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography></TableCell></TableRow> : null} + {!jobsResource.loading && !jobsResource.error && filteredJobs.map((job) => { const open = expanded.includes(job.id); const toneName = statusTone(job.status); const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main; @@ -690,7 +722,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col </React.Fragment> ); })} - {filteredJobs.length === 0 ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null} + {filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null} </TableBody> </Table> </Box> diff --git a/job-tracker-ui/src/components/KanbanBoard.tsx b/job-tracker-ui/src/components/KanbanBoard.tsx index dbd7ea5..f386278 100644 --- a/job-tracker-ui/src/components/KanbanBoard.tsx +++ b/job-tracker-ui/src/components/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useMemo, useState } from "react"; import { Box, @@ -15,8 +15,10 @@ import { alpha, useTheme } from "@mui/material/styles"; import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; import { api } from "../api"; +import ViewStateNotice from "./ViewStateNotice"; import { JobApplication } from "../types"; import { useI18n } from "../i18n/I18nProvider"; +import { useViewResource } from "../hooks/useViewResource"; const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; type Status = (typeof STATUSES)[number]; @@ -57,14 +59,23 @@ function statusLabel(t: (key: any, params?: any) => string, status: Status): str export default function KanbanBoard() { const theme = useTheme(); const { t } = useI18n(); - const [jobs, setJobs] = useState<JobApplication[]>([]); const [dragJobId, setDragJobId] = useState<number | null>(null); const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null); const [menuJobId, setMenuJobId] = useState<number | null>(null); - useEffect(() => { - api.get<JobApplication[]>("/jobapplications/board").then((r) => setJobs(r.data)); - }, []); + const jobsResource = useViewResource( + async () => { + const response = await api.get<JobApplication[]>("/jobapplications/board"); + return response.data; + }, + { + initialData: [], + errorMessage: "Unable to load the board right now.", + deps: [], + }, + ); + + const jobs = jobsResource.data; const groups = useMemo(() => { const map = new Map<string, JobApplication[]>(); @@ -85,12 +96,12 @@ export default function KanbanBoard() { if (!dragJobId) return; setDragJobId(null); await api.patch(`/jobapplications/${dragJobId}/status`, { status }); - setJobs((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j))); + jobsResource.setData((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j))); }; const setStatus = async (id: number, status: Status) => { await api.patch(`/jobapplications/${id}/status`, { status }); - setJobs((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j))); + jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j))); }; const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? ""); @@ -101,92 +112,102 @@ export default function KanbanBoard() { {t("kanbanHint")} </Typography> - <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}> - {STATUSES.map((status) => { - const c = toneColor(theme, status); - const list = groups.get(status) ?? []; - return ( - <Paper - key={status} - onDragOver={(e) => e.preventDefault()} - onDrop={() => void onDropTo(status)} - sx={{ - p: 1.5, - borderRadius: 3, - minHeight: 220, - border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.25 : 0.18)}`, - background: alpha(c, theme.palette.mode === "dark" ? 0.10 : 0.06), - }} - > - <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}> - <Typography variant="subtitle1" sx={{ fontWeight: 800, color: theme.palette.mode === "dark" ? "#f8fafc" : "inherit" }}> - {statusLabel(t, status)} - </Typography> - <Chip - size="small" - label={list.length} - sx={{ - fontWeight: 800, - color: alpha(c, theme.palette.mode === "dark" ? 0.95 : 0.9), - backgroundColor: alpha(c, theme.palette.mode === "dark" ? 0.18 : 0.12), - border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.35 : 0.22)}`, - }} - /> - </Box> + <ViewStateNotice + loading={jobsResource.loading} + error={jobsResource.error} + title="Unable to load the kanban board" + description="The board could not reach the API." + onRetry={jobsResource.reload} + /> - <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> - {list.map((j) => ( - <Card - key={j.id} - draggable - onDragStart={() => setDragJobId(j.id)} - onDragEnd={() => setDragJobId(null)} - sx={{ - cursor: "grab", - borderRadius: 3, - border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`, - background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.82)" : "rgba(255,255,255,0.96)", - backdropFilter: "blur(8px)", - color: theme.palette.mode === "dark" ? "#e5eefc" : "#0f172a", - }} - > - <CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}> - <Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}> - <Typography sx={{ fontWeight: 800, lineHeight: 1.25, color: theme.palette.mode === "dark" ? "#f8fafc" : "#0f172a" }}> - {j.company?.name ?? ""} - </Typography> - <IconButton - size="small" - sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a" }} - onClick={(e) => { - e.stopPropagation(); - setMenuJobId(j.id); - setMenuAnchor(e.currentTarget); - }} - > - <MoreHorizIcon fontSize="small" /> - </IconButton> - </Box> - <Typography variant="body2" sx={{ color: theme.palette.mode === "dark" ? "#cbd5e1" : "#475569" }}> - {j.jobTitle} - </Typography> - <Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}> - <Chip size="small" label={`${j.daysSince}d`} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} /> - {j.location ? <Chip size="small" label={j.location} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} /> : null} - </Box> - </CardContent> - </Card> - ))} - {list.length === 0 && ( - <Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}> - {t("kanbanDropHere")} + {!jobsResource.loading && !jobsResource.error ? ( + <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}> + {STATUSES.map((status) => { + const c = toneColor(theme, status); + const list = groups.get(status) ?? []; + return ( + <Paper + key={status} + onDragOver={(e) => e.preventDefault()} + onDrop={() => void onDropTo(status)} + sx={{ + p: 1.5, + borderRadius: 3, + minHeight: 220, + border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.25 : 0.18)}`, + background: alpha(c, theme.palette.mode === "dark" ? 0.10 : 0.06), + }} + > + <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}> + <Typography variant="subtitle1" sx={{ fontWeight: 800, color: theme.palette.mode === "dark" ? "#f8fafc" : "inherit" }}> + {statusLabel(t, status)} </Typography> - )} - </Box> - </Paper> - ); - })} - </Box> + <Chip + size="small" + label={list.length} + sx={{ + fontWeight: 800, + color: alpha(c, theme.palette.mode === "dark" ? 0.95 : 0.9), + backgroundColor: alpha(c, theme.palette.mode === "dark" ? 0.18 : 0.12), + border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.35 : 0.22)}`, + }} + /> + </Box> + + <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> + {list.map((j) => ( + <Card + key={j.id} + draggable + onDragStart={() => setDragJobId(j.id)} + onDragEnd={() => setDragJobId(null)} + sx={{ + cursor: "grab", + borderRadius: 3, + border: `1px solid ${alpha(c, theme.palette.mode === "dark" ? 0.22 : 0.14)}`, + background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.82)" : "rgba(255,255,255,0.96)", + backdropFilter: "blur(8px)", + color: theme.palette.mode === "dark" ? "#e5eefc" : "#0f172a", + }} + > + <CardContent sx={{ p: 1.25, "&:last-child": { pb: 1.25 } }}> + <Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}> + <Typography sx={{ fontWeight: 800, lineHeight: 1.25, color: theme.palette.mode === "dark" ? "#f8fafc" : "#0f172a" }}> + {j.company?.name ?? ""} + </Typography> + <IconButton + size="small" + sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a" }} + onClick={(e) => { + e.stopPropagation(); + setMenuJobId(j.id); + setMenuAnchor(e.currentTarget); + }} + > + <MoreHorizIcon fontSize="small" /> + </IconButton> + </Box> + <Typography variant="body2" sx={{ color: theme.palette.mode === "dark" ? "#cbd5e1" : "#475569" }}> + {j.jobTitle} + </Typography> + <Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}> + <Chip size="small" label={`${j.daysSince}d`} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} /> + {j.location ? <Chip size="small" label={j.location} sx={{ color: theme.palette.mode === "dark" ? "#e2e8f0" : "#0f172a", backgroundColor: theme.palette.mode === "dark" ? "rgba(148,163,184,0.18)" : "rgba(148,163,184,0.18)" }} /> : null} + </Box> + </CardContent> + </Card> + ))} + {list.length === 0 && ( + <Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}> + {t("kanbanDropHere")} + </Typography> + )} + </Box> + </Paper> + ); + })} + </Box> + ) : null} <Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => { setMenuAnchor(null); setMenuJobId(null); }}> {(["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const) diff --git a/job-tracker-ui/src/components/RemindersView.tsx b/job-tracker-ui/src/components/RemindersView.tsx index ea548de..4c5ca19 100644 --- a/job-tracker-ui/src/components/RemindersView.tsx +++ b/job-tracker-ui/src/components/RemindersView.tsx @@ -1,13 +1,15 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material"; import { api } from "../api"; +import ViewStateNotice from "./ViewStateNotice"; import { JobApplication } from "../types"; import { buildWorkflowPath, getReminderGroup, getWorkflowAction } from "../jobWorkflowSignals"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; +import { useViewResource } from "../hooks/useViewResource"; type ReminderGroups = { missingCv: JobApplication[]; @@ -41,26 +43,27 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin }); return ( - <Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}> - <Box> - <Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}> - {j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}>•</span> {j.jobTitle} - </Typography> - <Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}> - {j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null} - {(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={j.workflowSignal?.reason ?? j.followUpReason} variant="outlined" /> : null} - {j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null} - <Chip size="small" label={j.status} variant="outlined" /> + <Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}> + <Box> + <Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}> + {j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}>•</span> {j.jobTitle} + </Typography> + <Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}> + {j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null} + {(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={j.workflowSignal?.reason ?? j.followUpReason} variant="outlined" /> : null} + {j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null} + <Chip size="small" label={j.status} variant="outlined" /> + </Box> </Box> - </Box> - <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}> - <Button size="small" variant="outlined" onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button> - <Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button> - <Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button> - <Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button> - </Box> - </Paper> - )})} + <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}> + <Button size="small" variant="outlined" onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button> + <Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button> + <Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button> + <Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button> + </Box> + </Paper> + ); + })} </Box> ); } @@ -69,17 +72,20 @@ export default function RemindersView() { const navigate = useNavigate(); const { toast } = useToast(); const { t } = useI18n(); - const [items, setItems] = useState<JobApplication[]>([]); - const load = async () => { - const res = await api.get<JobApplication[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }); - setItems(res.data); - }; - - useEffect(() => { - void load(); - }, []); + const remindersResource = useViewResource( + async () => { + const res = await api.get<JobApplication[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }); + return Array.isArray(res.data) ? res.data : []; + }, + { + initialData: [], + errorMessage: "Unable to load reminders right now.", + deps: [], + }, + ); + const items = remindersResource.data; const grouped = useMemo(() => groupItems(items), [items]); const openJob = (job: JobApplication) => { @@ -91,7 +97,7 @@ export default function RemindersView() { const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); await api.patch(`/jobapplications/${id}/followup`, { followUpAt: d }); toast(daysFromNow === null ? t("remindersFollowUpCleared") : t("remindersFollowUpSet"), "success"); - await load(); + await remindersResource.reload(); } catch { toast(t("remindersFollowUpFailed"), "error"); } @@ -104,14 +110,24 @@ export default function RemindersView() { {t("remindersSubtitle")} </Typography> - <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> - <ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={openJob} onSetFollowUp={setFollowUp} /> - <ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={openJob} onSetFollowUp={setFollowUp} /> - <ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={openJob} onSetFollowUp={setFollowUp} /> - <ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={openJob} onSetFollowUp={setFollowUp} /> + <ViewStateNotice + loading={remindersResource.loading} + error={remindersResource.error} + title="Unable to load reminders" + description="The reminders view cannot reach the API right now." + onRetry={remindersResource.reload} + /> - {items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null} - </Box> + {!remindersResource.loading && !remindersResource.error ? ( + <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> + <ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={openJob} onSetFollowUp={setFollowUp} /> + <ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={openJob} onSetFollowUp={setFollowUp} /> + <ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={openJob} onSetFollowUp={setFollowUp} /> + <ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={openJob} onSetFollowUp={setFollowUp} /> + + {items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null} + </Box> + ) : null} <Divider sx={{ my: 2 }} /> <Typography variant="caption" sx={{ color: "text.secondary" }}> diff --git a/job-tracker-ui/src/components/UserManagementCard.tsx b/job-tracker-ui/src/components/UserManagementCard.tsx index 4001e68..e88a374 100644 --- a/job-tracker-ui/src/components/UserManagementCard.tsx +++ b/job-tracker-ui/src/components/UserManagementCard.tsx @@ -1,9 +1,8 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Box, Button, Paper, TextField, Typography } from "@mui/material"; import { api } from "../api"; -import { getAuthToken } from "../auth"; import { useToast } from "../toast"; import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; @@ -20,7 +19,6 @@ export default function UserManagementCard() { const { toast } = useToast(); const { confirmAction } = useDialogActions(); const { t } = useI18n(); - const token = getAuthToken(); const [supported, setSupported] = useState<boolean | null>(null); const [users, setUsers] = useState<UserDto[]>([]); @@ -30,8 +28,6 @@ export default function UserManagementCard() { const [newPassword, setNewPassword] = useState(""); const [newIsAdmin, setNewIsAdmin] = useState(false); - const canRender = useMemo(() => Boolean(token), [token]); - async function load() { setLoading(true); try { @@ -52,17 +48,15 @@ export default function UserManagementCard() { } useEffect(() => { - if (!canRender) { - setSupported(null); - setUsers([]); - return; - } void load(); + const onAuthChanged = () => { void load(); }; + window.addEventListener("auth-changed", onAuthChanged); + return () => window.removeEventListener("auth-changed", onAuthChanged); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [canRender]); + }, []); - if (!canRender) return null; if (supported === false) return null; + if (supported === null) return null; return ( <Paper sx={{ mt: 2, p: 2 }}> diff --git a/job-tracker-ui/src/components/ViewStateNotice.tsx b/job-tracker-ui/src/components/ViewStateNotice.tsx new file mode 100644 index 0000000..0eb9f9f --- /dev/null +++ b/job-tracker-ui/src/components/ViewStateNotice.tsx @@ -0,0 +1,45 @@ +import React from "react"; + +import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material"; + +import type { ViewResourceError } from "../hooks/useViewResource"; + +type Props = { + loading?: boolean; + error?: ViewResourceError | null; + title: string; + description?: string; + retryLabel?: string; + onRetry?: () => void | Promise<void>; + compact?: boolean; +}; + +export default function ViewStateNotice({ loading = false, error = null, title, description, retryLabel = "Retry", onRetry, compact = false }: Props) { + if (loading) { + return ( + <Box sx={{ py: compact ? 3 : 6, display: "flex", justifyContent: "center" }}> + <CircularProgress size={compact ? 24 : 28} /> + </Box> + ); + } + + if (!error) return null; + + const severity = error.kind === "unauthorized" ? "warning" : error.kind === "unavailable" ? "error" : "error"; + + return ( + <Alert + severity={severity} + sx={{ + mb: compact ? 1.5 : 2, + alignItems: "flex-start", + borderRadius: 3, + }} + action={error.retryable && onRetry ? <Button color="inherit" size="small" onClick={() => void onRetry()}>{retryLabel}</Button> : undefined} + > + <Typography sx={{ fontWeight: 800, mb: 0.35 }}>{title}</Typography> + {description ? <Typography variant="body2">{description}</Typography> : null} + {error.message ? <Typography variant="body2" sx={{ mt: 0.5 }}>{error.message}</Typography> : null} + </Alert> + ); +} diff --git a/job-tracker-ui/src/daily-control-loop.test.tsx b/job-tracker-ui/src/daily-control-loop.test.tsx index 344382d..f847203 100644 --- a/job-tracker-ui/src/daily-control-loop.test.tsx +++ b/job-tracker-ui/src/daily-control-loop.test.tsx @@ -209,7 +209,7 @@ test('reminders open action routes tailored-cv gaps into the tailored cv workspa await waitFor(() => { expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs'); }); - expect(await screen.findByText(/build the package here, then save the working copy back onto this job/i)).toBeInTheDocument(); + expect(await screen.findByText(/saved working material/i)).toBeInTheDocument(); }); test('job table urgency signals and next actions route into the shared workspace flow', async () => { @@ -231,6 +231,6 @@ test('job table urgency signals and next actions route into the shared workspace await waitFor(() => { expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs'); }); - expect(await screen.findByText(/build the package here, then save the working copy back onto this job/i)).toBeInTheDocument(); + expect(await screen.findByText(/saved working material/i)).toBeInTheDocument(); expect(await screen.findByText(/platform work/i)).toBeInTheDocument(); }); diff --git a/job-tracker-ui/src/hooks/useCompanies.ts b/job-tracker-ui/src/hooks/useCompanies.ts index fa5a941..4157c0d 100644 --- a/job-tracker-ui/src/hooks/useCompanies.ts +++ b/job-tracker-ui/src/hooks/useCompanies.ts @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; + import { api } from "../api"; import { Company } from "../types"; +import { useViewResource, ViewResourceError } from "./useViewResource"; let cachedCompanies: Company[] | null = null; let inflight: Promise<Company[]> | null = null; @@ -10,7 +12,7 @@ async function fetchCompanies(): Promise<Company[]> { if (inflight) return inflight; inflight = api - .get<Company[]>("/companies") + .get<Company[]>('/companies') .then((r) => { cachedCompanies = r.data; return r.data; @@ -26,25 +28,34 @@ export function invalidateCompaniesCache() { cachedCompanies = null; } -export function useCompanies() { - const [companies, setCompanies] = useState<Company[]>(cachedCompanies ?? []); - const [loading, setLoading] = useState(!cachedCompanies); +export function useCompanies(): { + companies: Company[]; + loading: boolean; + refreshing: boolean; + error: ViewResourceError | null; + reload: () => Promise<void>; +} { + const [cacheBust, setCacheBust] = useState(0); + const resource = useViewResource(fetchCompanies, { + initialData: cachedCompanies ?? [], + errorMessage: 'Unable to load companies right now.', + deps: [cacheBust], + }); useEffect(() => { - let mounted = true; - setLoading(!cachedCompanies); - fetchCompanies() - .then((c) => { - if (mounted) setCompanies(c); - }) - .finally(() => { - if (mounted) setLoading(false); - }); - return () => { - mounted = false; - }; - }, []); + if (!resource.error) { + cachedCompanies = resource.data; + } + }, [resource.data, resource.error]); - return { companies, loading }; + return { + companies: resource.data, + loading: resource.loading, + refreshing: resource.refreshing, + error: resource.error, + reload: async () => { + invalidateCompaniesCache(); + setCacheBust((value) => value + 1); + }, + }; } - diff --git a/job-tracker-ui/src/hooks/useViewResource.ts b/job-tracker-ui/src/hooks/useViewResource.ts new file mode 100644 index 0000000..179b52f --- /dev/null +++ b/job-tracker-ui/src/hooks/useViewResource.ts @@ -0,0 +1,99 @@ +import { DependencyList, Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; + +import { getApiErrorMessage } from "../api"; + +export type ViewResourceErrorKind = "unauthorized" | "unavailable" | "error"; + +export type ViewResourceError = { + kind: ViewResourceErrorKind; + message: string; + retryable: boolean; + status?: number; +}; + +export type ViewResourceState<T> = { + data: T; + loading: boolean; + refreshing: boolean; + error: ViewResourceError | null; + hasLoaded: boolean; + reload: () => Promise<void>; + setData: Dispatch<SetStateAction<T>>; +}; + +function normalizeError(error: any, fallback: string): ViewResourceError { + const status = error?.response?.status as number | undefined; + if (status === 401 || status === 403) { + return { + kind: "unauthorized", + message: getApiErrorMessage(error, fallback), + retryable: false, + status, + }; + } + + if (!status || status >= 500) { + return { + kind: "unavailable", + message: getApiErrorMessage(error, fallback), + retryable: true, + status, + }; + } + + return { + kind: "error", + message: getApiErrorMessage(error, fallback), + retryable: true, + status, + }; +} + +export function useViewResource<T>( + load: () => Promise<T>, + options: { + initialData: T; + errorMessage: string; + deps?: DependencyList; + enabled?: boolean; + }, +): ViewResourceState<T> { + const { initialData, errorMessage, deps = [], enabled = true } = options; + const [data, setData] = useState<T>(initialData); + const [loading, setLoading] = useState(enabled); + const [refreshing, setRefreshing] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [error, setError] = useState<ViewResourceError | null>(null); + + const reload = useCallback(async () => { + if (!enabled) return; + + setLoading((current) => !hasLoaded && current); + setRefreshing(hasLoaded); + try { + const next = await load(); + setData(next); + setError(null); + setHasLoaded(true); + } catch (err: any) { + setError(normalizeError(err, errorMessage)); + setHasLoaded(true); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [enabled, errorMessage, hasLoaded, load]); + + useEffect(() => { + if (!enabled) { + setLoading(false); + return; + } + + setLoading(!hasLoaded); + void reload(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, reload, ...deps]); + + return useMemo(() => ({ data, loading, refreshing, error, hasLoaded, reload, setData }), [data, error, hasLoaded, loading, refreshing, reload]); +} diff --git a/job-tracker-ui/src/login-page.test.tsx b/job-tracker-ui/src/login-page.test.tsx index ca4e126..c26ae53 100644 --- a/job-tracker-ui/src/login-page.test.tsx +++ b/job-tracker-ui/src/login-page.test.tsx @@ -52,8 +52,9 @@ describe('LoginPage', () => { consoleErrorSpy.mockRestore(); }); - it('stores auth token in session storage when remember me is unchecked', async () => { - mockedApi.post.mockResolvedValueOnce({ data: { accessToken: 'header.payload.sig', tokenType: 'Bearer' } } as any); + it('posts remember-me preference without storing an auth token in browser storage', async () => { + mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any); + mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any); renderLoginPage(); await screen.findByLabelText('Email'); @@ -63,9 +64,10 @@ describe('LoginPage', () => { await userEvent.click(screen.getByLabelText('Remember me')); await userEvent.click(screen.getByRole('button', { name: 'Sign in' })); - await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/login', { email: 'person@example.com', password: 'hunter2' })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/login', { email: 'person@example.com', password: 'hunter2', rememberMe: false })); + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me')); - expect(window.sessionStorage.getItem('authToken')).toBe('header.payload.sig'); + expect(window.sessionStorage.getItem('authToken')).toBeNull(); expect(window.localStorage.getItem('authToken')).toBeNull(); expect(window.localStorage.getItem('authTokenPersistence')).toBe('session'); }); diff --git a/job-tracker-ui/src/pages/LoginPage.tsx b/job-tracker-ui/src/pages/LoginPage.tsx index 84a52a4..7ede905 100644 --- a/job-tracker-ui/src/pages/LoginPage.tsx +++ b/job-tracker-ui/src/pages/LoginPage.tsx @@ -5,7 +5,7 @@ import { Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, T import { useLocation, useNavigate } from "react-router-dom"; import { api, getApiErrorMessage } from "../api"; -import { getRememberMePref, setAuthToken, setRememberMePref } from "../auth"; +import { getRememberMePref, setAuthPersistencePreference } from "../auth"; import GoogleAuthCard from "../components/GoogleAuthCard"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; @@ -44,9 +44,9 @@ export default function LoginPage() { setLoading(true); try { const url = mode === "register" ? "/auth/register" : "/auth/login"; - const res = await api.post<{ accessToken: string; tokenType: string }>(url, { email, password }); - setRememberMePref(rememberMe); - setAuthToken(res.data.accessToken, rememberMe ? "local" : "session"); + await api.post(url, { email, password, rememberMe }); + setAuthPersistencePreference(rememberMe ? "local" : "session"); + await api.get("/auth/me"); toast(t("signedIn"), "success"); navigate(nextPath, { replace: true }); } catch (e: any) { diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index 6f29770..39492ae 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -42,6 +42,12 @@ type ExtractionRun = { errorMessage?: string; }; +type QueuedCvRunResponse = { + queued: boolean; + extractionRunId: number; + status: string; +}; + type JobListResponse = { items: JobApplication[]; total: number; @@ -199,6 +205,7 @@ export default function ProfilePage() { const avatarInputRef = useRef<HTMLInputElement | null>(null); const [me, setMe] = useState<MeResponse | null>(null); const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState<string | null>(null); const [uploadingCv, setUploadingCv] = useState(false); const [improvingCv, setImprovingCv] = useState(false); const [rebuildingCv, setRebuildingCv] = useState(false); @@ -225,10 +232,12 @@ export default function ProfilePage() { const [reprocessingCv, setReprocessingCv] = useState(false); const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv()); const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]); + const runStatusRef = useRef<Record<number, string>>({}); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const loadProfile = useCallback(async () => { + setLoading(true); try { const [profileResponse, runsResponse, jobsResponse] = await Promise.all([ api.get<MeResponse>("/auth/me"), @@ -247,10 +256,14 @@ export default function ProfilePage() { setExtractionRuns(runsResponse.data ?? []); setSavedJobs(jobsResponse.data?.items ?? []); setHeadline(window.localStorage.getItem("profileHeadline") ?? ""); - } catch { + setLoadError(null); + } catch (error: any) { setMe(null); setExtractionRuns([]); setSavedJobs([]); + setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now.")); + } finally { + setLoading(false); } }, []); @@ -258,6 +271,31 @@ export default function ProfilePage() { void loadProfile(); }, [loadProfile]); + useEffect(() => { + const activeRuns = extractionRuns.filter((run) => run.status === "queued" || run.status === "running"); + if (activeRuns.length === 0) return; + + const timer = window.setInterval(() => { + void loadProfile(); + }, 4000); + + return () => window.clearInterval(timer); + }, [extractionRuns, loadProfile]); + + useEffect(() => { + const previous = runStatusRef.current; + for (const run of extractionRuns) { + const prior = previous[run.id]; + if ((prior === "queued" || prior === "running") && run.status === "applied") { + toast(`CV ${run.trigger} completed.`, "success"); + } + if ((prior === "queued" || prior === "running") && run.status === "failed") { + toast(run.errorMessage || `CV ${run.trigger} failed.`, "error"); + } + previous[run.id] = run.status; + } + }, [extractionRuns, toast]); + const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]); const isLocal = me?.provider === "local"; const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" "); @@ -305,6 +343,13 @@ export default function ProfilePage() { }} /> + {loadError ? ( + <Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>Retry</Button>}> + Unable to load profile. + <Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography> + </Alert> + ) : null} + <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}> <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}> @@ -413,7 +458,7 @@ export default function ProfilePage() { formData.append("file", file); setUploadingCv(true); try { - await api.post("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } }); + await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } }); await loadProfile(); toast(t("profileCvUploaded"), "success"); } catch (e: any) { @@ -432,10 +477,9 @@ export default function ProfilePage() { onClick={async () => { setRebuildingCv(true); try { - const res = await api.post<{ text?: string }>("/profile-cv/rebuild"); - if (res.data?.text) setProfileCvText(res.data.text); + const res = await api.post<QueuedCvRunResponse>("/profile-cv/rebuild"); await loadProfile(); - toast(t("profileCvRebuilt"), "success"); + toast(`Queued CV rebuild (run ${res.data.extractionRunId}).`, "info"); } catch (e: any) { toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error"); } finally { @@ -451,10 +495,9 @@ export default function ProfilePage() { onClick={async () => { setImprovingCv(true); try { - const res = await api.post<{ text?: string }>("/profile-cv/improve"); - if (res.data?.text) setProfileCvText(res.data.text); + const res = await api.post<QueuedCvRunResponse>("/profile-cv/improve"); await loadProfile(); - toast(t("profileCvImproved"), "success"); + toast(`Queued CV improve run (run ${res.data.extractionRunId}).`, "info"); } catch (e: any) { toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error"); } finally { @@ -470,9 +513,9 @@ export default function ProfilePage() { onClick={async () => { setReprocessingCv(true); try { - await api.post("/profile-cv/reprocess"); + const res = await api.post<QueuedCvRunResponse>("/profile-cv/reprocess"); await loadProfile(); - toast(t("profileCvReprocessed"), "success"); + toast(`Queued CV reprocess run (run ${res.data.extractionRunId}).`, "info"); } catch (e: any) { toast(String(e?.response?.data || e?.message || t("profileCvReprocessFailed")), "error"); } finally { diff --git a/job-tracker-ui/src/themePrefs.ts b/job-tracker-ui/src/themePrefs.ts index 46bb7bc..f694ce4 100644 --- a/job-tracker-ui/src/themePrefs.ts +++ b/job-tracker-ui/src/themePrefs.ts @@ -1,26 +1,9 @@ -import { decodeJwtPayload, getAuthToken } from "./auth"; +import { getAuthUserKey } from "./auth"; export type ThemeModePref = "system" | "light" | "dark"; export function getUserKeyFromToken(): string { - const token = getAuthToken(); - if (!token) return "anon"; - - const payload = decodeJwtPayload(token) ?? {}; - - const candidates = [ - payload.sub, - payload.nameid, - payload["nameid"], - payload["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"], - payload["http://schemas.microsoft.com/ws/2008/06/identity/claims/primarysid"], - ]; - - for (const c of candidates) { - if (typeof c === "string" && c.trim().length > 0) return c.trim(); - } - - return "anon"; + return getAuthUserKey(); } function k(base: string) { diff --git a/scripts/compare-cv-approved-fixtures.py b/scripts/compare-cv-approved-fixtures.py new file mode 100644 index 0000000..951780a --- /dev/null +++ b/scripts/compare-cv-approved-fixtures.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +import argparse +import importlib.util +import json +import pathlib +import sys +from typing import Any + +HELPER_PATH = pathlib.Path(__file__).with_name("prepare-cv-approved-fixtures.py") +HELPER_SPEC = importlib.util.spec_from_file_location("prepare_cv_approved_fixtures", HELPER_PATH) +if HELPER_SPEC is None or HELPER_SPEC.loader is None: + raise RuntimeError(f"Unable to load helper script: {HELPER_PATH}") +HELPER_MODULE = importlib.util.module_from_spec(HELPER_SPEC) +HELPER_SPEC.loader.exec_module(HELPER_MODULE) +normalize_fixture = HELPER_MODULE.normalize_fixture + + +def load_json(path: pathlib.Path) -> Any: + return json.loads(path.read_text(encoding="utf-8", errors="replace")) + + +def diff_values(expected: Any, actual: Any, path: str = "$") -> list[dict[str, Any]]: + diffs: list[dict[str, Any]] = [] + + if type(expected) is not type(actual): + diffs.append({"path": path, "expected": expected, "actual": actual, "kind": "type-mismatch"}) + return diffs + + if isinstance(expected, dict): + expected_keys = set(expected.keys()) + actual_keys = set(actual.keys()) + for key in sorted(expected_keys | actual_keys): + child_path = f"{path}.{key}" + if key not in expected: + diffs.append({"path": child_path, "expected": None, "actual": actual[key], "kind": "unexpected-key"}) + continue + if key not in actual: + diffs.append({"path": child_path, "expected": expected[key], "actual": None, "kind": "missing-key"}) + continue + diffs.extend(diff_values(expected[key], actual[key], child_path)) + return diffs + + if isinstance(expected, list): + if len(expected) != len(actual): + diffs.append({"path": path, "expected": f"len={len(expected)}", "actual": f"len={len(actual)}", "kind": "length-mismatch"}) + for index, (expected_item, actual_item) in enumerate(zip(expected, actual)): + diffs.extend(diff_values(expected_item, actual_item, f"{path}[{index}]")) + return diffs + + if expected != actual: + diffs.append({"path": path, "expected": expected, "actual": actual, "kind": "value-mismatch"}) + return diffs + + +def summarize_sections(diffs: list[dict[str, Any]]) -> dict[str, int]: + sections: dict[str, int] = {} + for diff in diffs: + path = diff["path"] + remainder = path[2:] if path.startswith('$.') else path + head = remainder.split('.', 1)[0].split('[', 1)[0] + sections[head] = sections.get(head, 0) + 1 + return dict(sorted(sections.items(), key=lambda item: (-item[1], item[0]))) + + +def render_markdown(results: list[dict[str, Any]], missing_expected: list[str], missing_actual: list[str], approved_dir: pathlib.Path, actual_dir: pathlib.Path) -> str: + total = len(results) + matched = sum(1 for result in results if result["diffCount"] == 0) + lines = [ + "# CV fixture comparison report", + "", + f"- Approved fixtures: `{approved_dir}`", + f"- Actual outputs: `{actual_dir}`", + f"- Compared files: {total}", + f"- Exact matches: {matched}", + f"- Files with differences: {total - matched}", + "", + ] + + if missing_expected: + lines.append("## Missing approved fixtures") + lines.extend(f"- {name}" for name in missing_expected) + lines.append("") + + if missing_actual: + lines.append("## Missing actual outputs") + lines.extend(f"- {name}" for name in missing_actual) + lines.append("") + + lines.append("## File results") + lines.append("") + lines.append("| File | Diffs | Top sections |") + lines.append("|---|---:|---|") + for result in results: + section_summary = ", ".join(f"{key}:{value}" for key, value in result["sectionCounts"].items()) or "—" + lines.append(f"| {result['file']} | {result['diffCount']} | {section_summary} |") + + lines.append("") + lines.append("## Detailed mismatches") + lines.append("") + for result in results: + if result["diffCount"] == 0: + continue + lines.append(f"### {result['file']}") + for diff in result["diffs"][:50]: + lines.append(f"- `{diff['path']}` ({diff['kind']})") + lines.append(f" - expected: `{json.dumps(diff['expected'], ensure_ascii=False)}`") + lines.append(f" - actual: `{json.dumps(diff['actual'], ensure_ascii=False)}`") + if result["diffCount"] > 50: + lines.append(f"- … {result['diffCount'] - 50} more differences omitted") + lines.append("") + + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Compare actual CV extractor outputs against approved fixtures.") + parser.add_argument("--approved", default="/home/pi/cvs/approved-jsons", help="Directory containing approved fixture JSON files") + parser.add_argument("--actual", required=True, help="Directory containing actual extractor output JSON files") + parser.add_argument("--output", help="Directory to write comparison artifacts. Defaults to <actual>/../comparison") + args = parser.parse_args() + + approved_dir = pathlib.Path(args.approved) + actual_dir = pathlib.Path(args.actual) + output_dir = pathlib.Path(args.output) if args.output else actual_dir.parent / "comparison" + + if not approved_dir.is_dir(): + print(f"Approved fixture directory not found: {approved_dir}", file=sys.stderr) + return 1 + if not actual_dir.is_dir(): + print(f"Actual output directory not found: {actual_dir}", file=sys.stderr) + return 1 + + approved_files = {path.name: path for path in approved_dir.glob("*.json")} + actual_files = {path.name: path for path in actual_dir.glob("*.json")} + + common_names = sorted(set(approved_files) & set(actual_files)) + missing_expected = sorted(set(actual_files) - set(approved_files)) + missing_actual = sorted(set(approved_files) - set(actual_files)) + + results: list[dict[str, Any]] = [] + for name in common_names: + expected = normalize_fixture(load_json(approved_files[name])) + actual = normalize_fixture(load_json(actual_files[name])) + diffs = diff_values(expected, actual) + results.append({ + "file": name, + "diffCount": len(diffs), + "sectionCounts": summarize_sections(diffs), + "diffs": diffs, + }) + + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "comparison.json").write_text(json.dumps({ + "approvedDir": str(approved_dir), + "actualDir": str(actual_dir), + "comparedFiles": len(results), + "missingApprovedFixtures": missing_expected, + "missingActualOutputs": missing_actual, + "results": results, + }, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + (output_dir / "comparison.md").write_text(render_markdown(results, missing_expected, missing_actual, approved_dir, actual_dir) + "\n", encoding="utf-8") + + print(f"Comparison JSON: {output_dir / 'comparison.json'}") + print(f"Comparison markdown: {output_dir / 'comparison.md'}") + print(f"Compared files: {len(results)}") + print(f"Files with differences: {sum(1 for result in results if result['diffCount'] > 0)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/export-cv-corpus-json.sh b/scripts/export-cv-corpus-json.sh new file mode 100755 index 0000000..8cc8b9f --- /dev/null +++ b/scripts/export-cv-corpus-json.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CORPUS_DIR="${CV_CORPUS_DIR:-$HOME/cvs}" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +EXPORT_ROOT="${CV_JSON_EXPORT_DIR:-$ROOT_DIR/tmp/cv-json-export/$TIMESTAMP}" +OUTPUT_ROOT="$EXPORT_ROOT/benchmark" +APPROVED_DIR="$EXPORT_ROOT/approved-fixtures" +EDITABLE_DIR="$EXPORT_ROOT/editable-json" +DEFAULT_IGNORE_PATTERNS="cv-template.pdf,Resume.en.pdf,EPS-*.pdf" +IGNORE_PATTERNS="${CV_BENCHMARK_IGNORE:-$DEFAULT_IGNORE_PATTERNS}" + +resolve_dotnet() { + if command -v dotnet >/dev/null 2>&1; then + command -v dotnet + return 0 + fi + + local candidates=( + "$HOME/.gsd/agent/bin/dotnet" + "$HOME/.dotnet/dotnet" + "/usr/bin/dotnet" + "/usr/local/bin/dotnet" + ) + + local candidate + for candidate in "${candidates[@]}"; do + if [[ -x "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +DOTNET_BIN="$(resolve_dotnet || true)" + +if [[ ! -d "$CORPUS_DIR" ]]; then + echo "CV corpus directory not found: $CORPUS_DIR" >&2 + exit 1 +fi + +if [[ -z "$DOTNET_BIN" ]]; then + echo "dotnet not found on PATH or known fallback locations." >&2 + echo "Checked: ~/.gsd/agent/bin/dotnet, ~/.dotnet/dotnet, /usr/bin/dotnet, /usr/local/bin/dotnet" >&2 + exit 1 +fi + +if [[ "$CORPUS_DIR" != "/home/pi/cvs" ]]; then + echo "This wrapper currently relies on the existing benchmark harness, which scans /home/pi/cvs." >&2 + echo "Set up a symlink or move the corpus there, or rerun with CV_CORPUS_DIR=/home/pi/cvs." >&2 + exit 1 +fi + +mkdir -p "$OUTPUT_ROOT" "$APPROVED_DIR" "$EDITABLE_DIR" + +echo "Exporting structured CV JSON from: $CORPUS_DIR" +echo "Working directory: $EXPORT_ROOT" +echo "Ignoring files: $IGNORE_PATTERNS" + +CV_BENCHMARK_OUTPUT_DIR="$OUTPUT_ROOT" \ +CV_BENCHMARK_APPROVED_DIR="$APPROVED_DIR" \ +CV_BENCHMARK_IGNORE="$IGNORE_PATTERNS" \ + "$DOTNET_BIN" test "$ROOT_DIR/JobTrackerApi.Tests/JobTrackerApi.Tests.csproj" --filter CvCorpusHarnessTests /p:DisableSourceControlManagerQueries=true + +if [[ -d "$OUTPUT_ROOT/outputs" ]]; then + find "$OUTPUT_ROOT/outputs" -maxdepth 1 -name '*.json' -print0 | while IFS= read -r -d '' file; do + base="$(basename "$file")" + cp "$file" "$EDITABLE_DIR/$base" + done +fi + +cat <<EOF + +Done. + +Generated files: +- Latest parser output: $OUTPUT_ROOT/outputs +- Editable copies: $EDITABLE_DIR +- Candidate fixtures: $OUTPUT_ROOT/candidate-fixtures +- Summary report: $OUTPUT_ROOT/report.md +- Machine index: $OUTPUT_ROOT/index.json + +Recommended workflow: +1. Edit files in: $EDITABLE_DIR +2. Keep only the fields you really want the extractor to produce. +3. Once reviewed, copy the corrected files into an approved fixtures directory. +4. Re-run the benchmark and compare actual vs approved output. +EOF diff --git a/scripts/prepare-cv-approved-fixtures.py b/scripts/prepare-cv-approved-fixtures.py new file mode 100644 index 0000000..b99faa7 --- /dev/null +++ b/scripts/prepare-cv-approved-fixtures.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +import argparse +import json +import pathlib +import re +import sys +from typing import Any + +QUALIFICATION_LEVEL_MAP = { + "secondary": "Secondary", + "diploma/certificate": "Diploma/Certificate", + "diploma": "Diploma/Certificate", + "certificate": "Diploma/Certificate", + "bachelor": "Bachelor", + "bachelor's": "Bachelor", + "bachelors": "Bachelor", + "master": "Master", + "master's": "Master", + "masters": "Master", + "phd": "PhD", + "doctorate": "PhD", + "other": "Other", +} + +TOP_LEVEL_KEYS = { + "version", + "contact", + "summary", + "jobs", + "education", + "certifications", + "projects", + "skills", + "languages", + "interests", + "otherSections", +} + +BARE_QUESTION_RE = re.compile(r'(:\s*)\?(\s*[,}\]])') +BARE_NULL_RE = re.compile(r'(:\s*)Null(\s*[,}\]])') + + +def load_relaxed_json(path: pathlib.Path) -> Any: + raw = path.read_text(encoding="utf-8", errors="replace") + relaxed = BARE_QUESTION_RE.sub(r'\1null\2', raw) + relaxed = BARE_NULL_RE.sub(r'\1null\2', relaxed) + return json.loads(relaxed) + + +def normalize_qualification_level(value: Any) -> Any: + if value is None: + return None + if not isinstance(value, str): + return value + normalized = QUALIFICATION_LEVEL_MAP.get(value.strip().lower()) + return normalized if normalized is not None else value + + +def normalize_value(value: Any) -> Any: + if isinstance(value, dict): + out = {} + for key, item in value.items(): + if key in {"metadata", "sections"}: + continue + out[key] = normalize_value(item) + return out + if isinstance(value, list): + return [normalize_value(item) for item in value] + if isinstance(value, str): + return value + return value + + +def normalize_fixture(payload: dict[str, Any]) -> dict[str, Any]: + cleaned = {key: normalize_value(value) for key, value in payload.items() if key in TOP_LEVEL_KEYS} + + cleaned.setdefault("version", "1") + cleaned.setdefault("contact", {}) + cleaned.setdefault("summary", []) + cleaned.setdefault("jobs", []) + cleaned.setdefault("education", []) + cleaned.setdefault("certifications", []) + cleaned.setdefault("projects", []) + cleaned.setdefault("skills", []) + cleaned.setdefault("languages", []) + cleaned.setdefault("interests", []) + cleaned.setdefault("otherSections", []) + + for education in cleaned.get("education", []): + if isinstance(education, dict): + education["qualificationLevel"] = normalize_qualification_level(education.get("qualificationLevel")) + + return cleaned + + +def main() -> int: + parser = argparse.ArgumentParser(description="Prepare hand-edited CV JSON fixtures for benchmark comparison.") + parser.add_argument("--source", default="/home/pi/cvs/jsons", help="Directory containing hand-edited JSON fixtures") + parser.add_argument("--output", default="/home/pi/cvs/approved-jsons", help="Directory to write cleaned approved fixtures") + args = parser.parse_args() + + source = pathlib.Path(args.source) + output = pathlib.Path(args.output) + + if not source.is_dir(): + print(f"Source directory not found: {source}", file=sys.stderr) + return 1 + + output.mkdir(parents=True, exist_ok=True) + + files = sorted(source.glob("*.json")) + if not files: + print(f"No JSON files found in {source}", file=sys.stderr) + return 1 + + for path in files: + try: + payload = load_relaxed_json(path) + except json.JSONDecodeError as exc: + print(f"Invalid JSON in {path.name}: line {exc.lineno}, column {exc.colno}: {exc.msg}", file=sys.stderr) + continue + if not isinstance(payload, dict): + print(f"Skipping non-object JSON fixture: {path.name}", file=sys.stderr) + continue + normalized = normalize_fixture(payload) + destination = output / path.name + destination.write_text(json.dumps(normalized, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"Prepared {destination}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/summarizer/.dockerignore b/tools/summarizer/.dockerignore new file mode 100644 index 0000000..f10f3d2 --- /dev/null +++ b/tools/summarizer/.dockerignore @@ -0,0 +1,10 @@ +.venv +__pycache__ +.pytest_cache +tmp +*.out +*.err +*.pyc +*.pyo +*.pyd +.pytest_cache/ diff --git a/tools/summarizer/README.md b/tools/summarizer/README.md index 05dc32f..4903dee 100644 --- a/tools/summarizer/README.md +++ b/tools/summarizer/README.md @@ -54,7 +54,7 @@ The script: - writes pytest cache under `tmp/pytest-cache` to avoid stale root-owned `.pytest_cache` directories ## API -- `GET /health` — health check and runtime capabilities, including Ollama version/model metadata when configured +- `GET /health` — health check and runtime capabilities, including lazy model state (`model_loaded`, `model_disabled`, `summarize_available`, `model_load_error`) plus Ollama version/model metadata when configured - `POST /summarize` — JSON body `{ "text": "...", "max_length": 150, "min_length": 30 }` - `POST /extract-text` — multipart file upload, returns extracted text and OCR metadata - `POST /cv/classify-block` — JSON body `{ "block": "..." }`, uses Ollama when `OLLAMA_MODEL` is configured diff --git a/tools/summarizer/app.py b/tools/summarizer/app.py index d9e4050..db72109 100644 --- a/tools/summarizer/app.py +++ b/tools/summarizer/app.py @@ -27,6 +27,17 @@ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "") SKIP_MODEL_LOAD = os.getenv("AI_SERVICE_SKIP_MODEL_LOAD", "") == "1" +EAGER_MODEL_LOAD = os.getenv("AI_SERVICE_EAGER_MODEL_LOAD", "") == "1" + + +tokenizer = None +model = None +device = torch.device("cpu") +GPU_AVAILABLE = False +GPU_NAME = None +MODEL_LOAD_ERROR = "Model loading is disabled by AI_SERVICE_SKIP_MODEL_LOAD." if SKIP_MODEL_LOAD else None +MODEL_LOADED = False +MODEL_DISABLED = SKIP_MODEL_LOAD def _load_runtime(): @@ -40,10 +51,31 @@ def _load_runtime(): return tokenizer, model, device, has_cuda, gpu_name -if SKIP_MODEL_LOAD: - tokenizer, model, device, GPU_AVAILABLE, GPU_NAME = None, None, torch.device("cpu"), False, None -else: - tokenizer, model, device, GPU_AVAILABLE, GPU_NAME = _load_runtime() +def _ensure_runtime_loaded(): + global tokenizer, model, device, GPU_AVAILABLE, GPU_NAME, MODEL_LOAD_ERROR, MODEL_LOADED + if MODEL_DISABLED: + MODEL_LOAD_ERROR = "Model loading is disabled by AI_SERVICE_SKIP_MODEL_LOAD." + return False + if MODEL_LOADED and tokenizer is not None and model is not None: + return True + try: + tokenizer, model, device, GPU_AVAILABLE, GPU_NAME = _load_runtime() + MODEL_LOAD_ERROR = None + MODEL_LOADED = True + return True + except Exception as exc: + tokenizer, model = None, None + device = torch.device("cpu") + GPU_AVAILABLE = False + GPU_NAME = None + MODEL_LOADED = False + MODEL_LOAD_ERROR = str(exc) + return False + + +if EAGER_MODEL_LOAD and not SKIP_MODEL_LOAD: + _ensure_runtime_loaded() + cache = TTLCache(maxsize=1024, ttl=60 * 60) @@ -54,6 +86,10 @@ class SummarizeRequest(BaseModel): top_skills: int = Field(default=8, ge=3, le=12) +class CvNormalizeRequest(BaseModel): + text: str = Field(min_length=1, max_length=50000) + + class CvClassifyBlockRequest(BaseModel): block: str = Field(min_length=1, max_length=6000) @@ -127,6 +163,10 @@ async def health(): "gpu_name": GPU_NAME, "ocr_available": True, "ocr_languages": OCR_LANGUAGES, + "model_loaded": MODEL_LOADED, + "model_disabled": MODEL_DISABLED, + "summarize_available": MODEL_LOADED and not MODEL_DISABLED, + "model_load_error": MODEL_LOAD_ERROR, **_ollama_status(), } @@ -324,8 +364,8 @@ def _role_focused_excerpt(text: str) -> dict: def _model_summarize(text: str, max_length: int, min_length: int) -> str: - if tokenizer is None or model is None: - raise HTTPException(status_code=503, detail="Summarizer model is not loaded.") + if not _ensure_runtime_loaded() or tokenizer is None or model is None: + raise HTTPException(status_code=503, detail=MODEL_LOAD_ERROR or "Summarizer model is not loaded.") inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=1024) input_ids = inputs.input_ids.to(device) attention_mask = inputs.attention_mask.to(device) if hasattr(inputs, "attention_mask") else None @@ -363,7 +403,7 @@ def _ollama_generate_json(prompt: str): ) try: - with urllib_request.urlopen(req, timeout=30) as response: + with urllib_request.urlopen(req, timeout=120) as response: body = json.loads(response.read().decode("utf-8")) except HTTPError as ex: raise HTTPException(status_code=502, detail=f"Ollama request failed with {ex.code}.") @@ -384,6 +424,64 @@ def _ollama_generate_json(prompt: str): raise HTTPException(status_code=502, detail="Ollama did not return valid JSON.") +@app.post("/cv/normalize") +async def normalize_cv(req: CvNormalizeRequest): + prompt = f""" +You normalize messy CV text into parser-friendly master-CV text. +Return ONLY valid JSON with this exact shape: +{{ + "confidence": 0.0, + "reason": "short reason", + "normalized_text": "string" +}} + +Rules for normalized_text: +- Preserve facts only. Do not invent. +- Use markdown section headings exactly like these when data exists: + # Contact + # Professional Summary + # Work Experience + # Education + # Skills + # Languages + # Interests +- Under # Contact, put one plain value per line, no labels unless unavoidable: + Full name line + email line + phone line + website line + location line +- Under # Professional Summary, write 1-3 plain sentences or bullet lines. +- Preserve explicitly mentioned technologies, tools, and methods as skills when they appear in the source. +- Never output helper words like "line", "value", "field", or "item". +- Under # Work Experience, for each job use this exact shape: + Job title only + Company, Location + 2019 - Present + - bullet + - bullet +- Under # Education, for each entry use this exact shape: + Qualification line + Institution, Location line + 2016 - 2019 line + - detail +- Under # Skills and # Languages, use one bullet per item. +- Remove OCR/layout noise. +- Do not output placeholders like Not specified. +- If uncertain, omit the field/line rather than invent. + +CV text: +{req.text.strip()} +""".strip() + + parsed = _ollama_generate_json(prompt) + return { + "confidence": parsed.get("confidence"), + "reason": parsed.get("reason"), + "normalized_text": parsed.get("normalized_text"), + } + + @app.post("/cv/classify-block") async def classify_cv_block(req: CvClassifyBlockRequest): prompt = f""" diff --git a/tools/summarizer/tests/test_app.py b/tools/summarizer/tests/test_app.py index 791554c..8e3b730 100644 --- a/tools/summarizer/tests/test_app.py +++ b/tools/summarizer/tests/test_app.py @@ -1,5 +1,4 @@ import importlib -import os import sys from pathlib import Path @@ -11,16 +10,23 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -def load_app_module(monkeypatch): - monkeypatch.setenv("AI_SERVICE_SKIP_MODEL_LOAD", "1") - monkeypatch.delenv("OLLAMA_MODEL", raising=False) +def load_app_module(monkeypatch, *, skip_model_load=True, ollama_model=None): + if skip_model_load: + monkeypatch.setenv("AI_SERVICE_SKIP_MODEL_LOAD", "1") + else: + monkeypatch.delenv("AI_SERVICE_SKIP_MODEL_LOAD", raising=False) + monkeypatch.delenv("AI_SERVICE_EAGER_MODEL_LOAD", raising=False) + if ollama_model is None: + monkeypatch.delenv("OLLAMA_MODEL", raising=False) + else: + monkeypatch.setenv("OLLAMA_MODEL", ollama_model) if "app" in sys.modules: del sys.modules["app"] module = importlib.import_module("app") return importlib.reload(module) -def test_health_reports_runtime_without_ollama(monkeypatch): +def test_health_reports_runtime_without_ollama_and_without_forcing_model_load(monkeypatch): module = load_app_module(monkeypatch) client = TestClient(module.app) @@ -30,12 +36,46 @@ def test_health_reports_runtime_without_ollama(monkeypatch): payload = response.json() assert payload["ok"] is True assert payload["device"] == "cpu" + assert payload["model_loaded"] is False + assert payload["model_disabled"] is True + assert payload["summarize_available"] is False + assert "disabled" in payload["model_load_error"].lower() assert payload["ollama_configured"] is False assert payload["ollama_model"] is None assert payload["ollama_installed_models"] == [] assert payload["ollama_loaded_models"] == [] +def test_summarize_returns_503_with_explicit_reason_when_model_loading_is_disabled(monkeypatch): + module = load_app_module(monkeypatch) + client = TestClient(module.app) + + response = client.post("/summarize", json={"text": "Platform engineering role with APIs and Python experience."}) + + assert response.status_code == 503 + payload = response.json() + assert "disabled" in payload["detail"].lower() + + +def test_health_reports_ollama_unreachable_when_configured_but_not_available(monkeypatch): + module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b") + + def boom(path: str): + raise OSError("connection refused") + + monkeypatch.setattr(module, "_ollama_json", boom) + client = TestClient(module.app) + + response = client.get("/health") + + assert response.status_code == 200 + payload = response.json() + assert payload["ollama_configured"] is True + assert payload["ollama_reachable"] is False + assert payload["ollama_model"] == "qwen2.5:7b" + assert payload["ollama_model_available"] is False + + def test_classify_block_returns_structured_json(monkeypatch): module = load_app_module(monkeypatch)