From 3ef3192e6cb821801428ce6b771dcc54cc6ec0ff Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 2 Jul 2026 21:21:45 +0200 Subject: [PATCH 01/27] docs: record next-session skill suggestions in handoff notes Co-Authored-By: Claude Fable 5 --- docs/jobbjakt-next-session.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/jobbjakt-next-session.md b/docs/jobbjakt-next-session.md index 8b6dc78..f6d3e02 100644 --- a/docs/jobbjakt-next-session.md +++ b/docs/jobbjakt-next-session.md @@ -79,6 +79,14 @@ Mitigation has been added in deploy script, but if it happens again check: 3. Final UX polish pass on profile/job details/attachments 4. Dashboard + system polish +## Useful skills to apply next time +- `accessibility` + - use for the final UI polish/a11y pass across dialogs, forms, focus states, contrast, keyboard support, and screen-reader naming +- `agent-browser` + - use for live verification of local or deployed Jobbjakt flows, screenshots, route checks, admin/system checks, and browser-based a11y smoke testing +- `code-optimizer` + - use for a targeted performance/code-quality audit after the current feature/polish work stabilizes + ## Files most relevant next time - `JobTrackerApi/Controllers/JobApplicationsController.cs` - `JobTrackerApi/Controllers/ProfileCvController.cs` From 29325a20484f82369359809f97ad0d160608feb8 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 2 Jul 2026 21:21:46 +0200 Subject: [PATCH 02/27] chore(ui): bump nginx base image to 1.29.8-alpine Co-Authored-By: Claude Fable 5 --- job-tracker-ui/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/job-tracker-ui/Dockerfile b/job-tracker-ui/Dockerfile index 5cd3e0e..c81266d 100644 --- a/job-tracker-ui/Dockerfile +++ b/job-tracker-ui/Dockerfile @@ -14,7 +14,7 @@ RUN npm ci COPY . . RUN npm run build -FROM nginx:1.27-alpine +FROM nginx:1.29.8-alpine COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build /app/build /usr/share/nginx/html From aa43ada16a7127b47deeefcf474d452f86d1743e Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 2 Jul 2026 21:21:47 +0200 Subject: [PATCH 03/27] chore: add Windows PowerShell variant of Ollama CV startup script Co-Authored-By: Claude Fable 5 --- scripts/start-ollama-cv.ps1 | 100 ++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 scripts/start-ollama-cv.ps1 diff --git a/scripts/start-ollama-cv.ps1 b/scripts/start-ollama-cv.ps1 new file mode 100644 index 0000000..f9f1a98 --- /dev/null +++ b/scripts/start-ollama-cv.ps1 @@ -0,0 +1,100 @@ +# PowerShell equivalent of start-ollama-cv.sh +# Starts Ollama service, pulls model if needed, waits, then restarts AI service + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Change to the parent directory of scripts +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location (Join-Path $scriptDir '..') + +$MODEL = if ($env:OLLAMA_MODEL) { $env:OLLAMA_MODEL } else { 'qwen2.5:7b' } +$OLLAMA_WAIT_SECONDS = if ($env:OLLAMA_WAIT_SECONDS) { [int]$env:OLLAMA_WAIT_SECONDS } else { 180 } +$PULL_WAIT_SECONDS = if ($env:OLLAMA_PULL_WAIT_SECONDS) { [int]$env:OLLAMA_PULL_WAIT_SECONDS } else { 1800 } + +function compose { + docker compose @args +} + +function wait_for_ollama { + $deadline = (Get-Date).AddSeconds($OLLAMA_WAIT_SECONDS) + while ((Get-Date) -lt $deadline) { + try { + compose exec -T ollama ollama list | Out-Null + return $true + } catch { + # Ignore errors, just wait + } + Start-Sleep -Seconds 3 + } + return $false +} + +function model_present { + try { + $models = compose exec -T ollama ollama list 2>$null | Select-Object -Skip 1 | ForEach-Object { $_.Split()[0] } + return $models -contains $MODEL + } catch { + return $false + } +} + +function wait_for_model { + $deadline = (Get-Date).AddSeconds($PULL_WAIT_SECONDS) + while ((Get-Date) -lt $deadline) { + if (model_present) { + return $true + } + Start-Sleep -Seconds 5 + } + return $false +} + +Write-Host "Starting Ollama service..." +compose up -d ollama + +if (-not (wait_for_ollama)) { + Write-Host "Ollama did not become ready within ${OLLAMA_WAIT_SECONDS}s." + try { compose logs --tail=200 ollama } catch { } + exit 1 +} + +Write-Host "Ollama is responding." + +if (model_present) { + Write-Host "Model already present: $MODEL" +} else { + Write-Host "Pulling Ollama model: $MODEL" + try { + compose exec -T ollama ollama pull $MODEL + } catch { + Write-Host "Model pull command failed." + try { compose logs --tail=200 ollama } catch { } + exit 1 + } +} + +if (-not (wait_for_model)) { + Write-Host "Model ${MODEL} did not appear within ${PULL_WAIT_SECONDS}s." + try { compose exec -T ollama ollama list } catch { } + exit 1 +} + +Write-Host "Ollama model ready: $MODEL" + +Write-Host "Restarting AI service so it can use the ready Ollama model." +compose up -d ai-service + +try { + $state = compose ps ai-service --format '{{.State}}' 2>$null | Select-Object -First 1 | ForEach-Object { $_.ToLower().Trim() } + if ($state -ne 'running') { + Write-Host "AI service is not running after Ollama warmup." + try { compose logs --tail=200 ai-service } catch { } + exit 1 + } +} catch { + Write-Host "Failed to check AI service status." + exit 1 +} + +Write-Host "Ollama warmup complete." \ No newline at end of file From 519c32efd70e0158743b49c1bebd1d9ad662be4e Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 2 Jul 2026 21:23:50 +0200 Subject: [PATCH 04/27] security: untrack DataProtection keys and runtime exports; remove dead legacy controllers - git rm --cached on committed DataProtection key XMLs (keys/, JobTrackerApi/keys/) and daily export JSON snapshots; extend .gitignore so runtime data (keys, exports, CV artifacts/exports/benchmarks) can never be committed again. - Delete root Controller/ stubs: an early prototype compiled by no project (JobTrackerApi excludes them; JobTrackerBackend globs only JobTrackerApi/Controllers). - NOTE: the removed key XMLs remain in git history; rotating DataProtection keys on the server is recommended. Co-Authored-By: Claude Fable 5 --- .gitignore | 7 ++ Controller/AttachmentsController.cs | 23 ----- Controller/CompaniesController.cs | 27 ----- Controller/CorrespondenceController.cs | 34 ------- Controller/JobApplicationsController.cs | 45 --------- .../exports/daily_export_20260325.json | 99 ------------------- .../exports/daily_export_20260326.json | 99 ------------------- ...y-9a89a42c-d2bd-4770-83fb-5930685432db.xml | 16 --- ...y-b3ca4672-1056-4ac2-ba47-0432608a4115.xml | 16 --- 9 files changed, 7 insertions(+), 359 deletions(-) delete mode 100644 Controller/AttachmentsController.cs delete mode 100644 Controller/CompaniesController.cs delete mode 100644 Controller/CorrespondenceController.cs delete mode 100644 Controller/JobApplicationsController.cs delete mode 100644 JobTrackerApi/exports/daily_export_20260325.json delete mode 100644 JobTrackerApi/exports/daily_export_20260326.json delete mode 100644 JobTrackerApi/keys/key-9a89a42c-d2bd-4770-83fb-5930685432db.xml delete mode 100644 keys/key-b3ca4672-1056-4ac2-ba47-0432608a4115.xml diff --git a/.gitignore b/.gitignore index 6d8c7e0..48d6b05 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,13 @@ todo jobtracker.txt tmp/ /tmp/ +# Runtime data that must never be committed (DataProtection keys, exports, CV artifacts) +keys/ +JobTrackerApi/exports/ +JobTrackerApi/CvArtifacts/ +JobTrackerApi/CvExports/ +JobTrackerApi/CvBenchmarks/ + # Local app data *.db *.db-* diff --git a/Controller/AttachmentsController.cs b/Controller/AttachmentsController.cs deleted file mode 100644 index 30f7ecd..0000000 --- a/Controller/AttachmentsController.cs +++ /dev/null @@ -1,23 +0,0 @@ -[ApiController] -[Route("api/[controller]")] -public class AttachmentsController : ControllerBase -{ - private readonly IWebHostEnvironment _env; - public AttachmentsController(IWebHostEnvironment env) => _env = env; - - [HttpPost] - public async Task Upload([FromForm] IFormFileCollection files, [FromForm] int jobId) - { - var folder = Path.Combine(_env.ContentRootPath, "Attachments", jobId.ToString()); - Directory.CreateDirectory(folder); - - foreach (var file in files) - { - var path = Path.Combine(folder, file.FileName); - using var stream = new FileStream(path, FileMode.Create); - await file.CopyToAsync(stream); - } - - return Ok(); - } -} \ No newline at end of file diff --git a/Controller/CompaniesController.cs b/Controller/CompaniesController.cs deleted file mode 100644 index c9e6d11..0000000 --- a/Controller/CompaniesController.cs +++ /dev/null @@ -1,27 +0,0 @@ -using JobTrackerApi.Data; -using JobTrackerApi.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace JobTrackerApi.Controllers -{ - [ApiController] - [Route("api/[controller]")] - public class CompaniesController : ControllerBase - { - private readonly JobTrackerContext _context; - public CompaniesController(JobTrackerContext context) => _context = context; - - [HttpGet] - public async Task> Get() => - await _context.Companies.Include(c => c.Jobs).ToListAsync(); - - [HttpPost] - public async Task> Post(Company company) - { - _context.Companies.Add(company); - await _context.SaveChangesAsync(); - return CreatedAtAction(nameof(Get), new { id = company.Id }, company); - } - } -} \ No newline at end of file diff --git a/Controller/CorrespondenceController.cs b/Controller/CorrespondenceController.cs deleted file mode 100644 index 79fac7f..0000000 --- a/Controller/CorrespondenceController.cs +++ /dev/null @@ -1,34 +0,0 @@ -using JobTrackerApi.Data; -using JobTrackerApi.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace JobTrackerApi.Controllers -{ - [ApiController] - [Route("api/[controller]")] - public class CorrespondenceController : ControllerBase - { - private readonly JobTrackerContext _context; - public CorrespondenceController(JobTrackerContext context) => _context = context; - - // GET all messages for a job - [HttpGet("{jobId}")] - public async Task> GetForJob(int jobId) - { - return await _context.Correspondences - .Where(c => c.JobApplicationId == jobId) - .OrderBy(c => c.Date) - .ToListAsync(); - } - - // POST new message - [HttpPost] - public async Task> Post(Correspondence message) - { - _context.Correspondences.Add(message); - await _context.SaveChangesAsync(); - return CreatedAtAction(nameof(GetForJob), new { jobId = message.JobApplicationId }, message); - } - } -} \ No newline at end of file diff --git a/Controller/JobApplicationsController.cs b/Controller/JobApplicationsController.cs deleted file mode 100644 index 4e3b9b8..0000000 --- a/Controller/JobApplicationsController.cs +++ /dev/null @@ -1,45 +0,0 @@ -using JobTrackerApi.Data; -using JobTrackerApi.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace JobTrackerApi.Controllers -{ - [ApiController] - [Route("api/[controller]")] - public class JobApplicationsController : ControllerBase - { - private readonly JobTrackerContext _context; - public JobApplicationsController(JobTrackerContext context) => _context = context; - - [HttpGet] - public async Task> Get() => - await _context.JobApplications.Include(j => j.Company).ToListAsync(); - - [HttpPost] - public async Task> Post(JobApplication job) - { - _context.JobApplications.Add(job); - await _context.SaveChangesAsync(); - return CreatedAtAction(nameof(Get), new { id = job.Id }, job); - } - - [HttpPut("{id}")] - public async Task Put(int id, JobApplication updatedJob) - { - var job = await _context.JobApplications.FindAsync(id); - if (job == null) return NotFound(); - - job.JobTitle = updatedJob.JobTitle; - job.Status = updatedJob.Status; - job.ResponseReceived = updatedJob.ResponseReceived; - job.ResponseDate = updatedJob.ResponseDate; - job.Notes = updatedJob.Notes; - job.CoverLetterText = updatedJob.CoverLetterText; - job.JobUrl = updatedJob.JobUrl; - await _context.SaveChangesAsync(); - - return NoContent(); - } - } -} \ No newline at end of file diff --git a/JobTrackerApi/exports/daily_export_20260325.json b/JobTrackerApi/exports/daily_export_20260325.json deleted file mode 100644 index 807f757..0000000 --- a/JobTrackerApi/exports/daily_export_20260325.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "Version": "dailyexport.v1", - "CreatedAt": "2026-03-25T02:00:00.0368687+01:00", - "Companies": [ - { - "Id": 1, - "OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c", - "Name": "Acme Browser QA", - "Location": null, - "Source": null, - "RecruiterName": "Maria Recruiter", - "RecruiterEmail": "maria@acme.test", - "RecruiterLinkedIn": null, - "LastContactedAt": "2026-03-24T11:15:21.4772436", - "NextContactAt": "2026-03-24T00:00:00", - "PipelineStage": null - } - ], - "JobApplications": [ - { - "Id": 1, - "OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c", - "JobTitle": "Backend Developer", - "CompanyId": 1, - "Company": null, - "Status": "Waiting", - "DateApplied": "2026-03-01T13:00:00+01:00", - "Location": null, - "Salary": null, - "NextAction": null, - "FollowUpAt": "2026-03-24T00:00:00", - "FeedbackRequestedAt": null, - "RecruiterMessageDraft": "Saved browser recruiter message", - "HasResume": true, - "HasCoverLetter": true, - "HasPortfolio": false, - "HasOtherAttachment": false, - "IsDeleted": false, - "DeletedAt": null, - "ResponseReceived": true, - "ResponseDate": null, - "Notes": "Browser-seeded notes\n\n\u003C\u003C\u003CAPPLICATION_ANSWER_DRAFT\u003E\u003E\u003E\nSaved browser application answer\n\u003C\u003C\u003CEND_APPLICATION_ANSWER_DRAFT\u003E\u003E\u003E", - "CoverLetterText": "Saved browser cover letter", - "JobUrl": "https://example.test/backend-developer", - "Description": "Need .NET APIs and strong stakeholder communication.", - "TranslatedDescription": null, - "DescriptionLanguage": null, - "Tags": "[\u0022.NET\u0022, \u0022APIs\u0022, \u0022Communication\u0022]", - "Deadline": null, - "ShortSummary": "Strong overlap in backend API delivery.", - "TailoredCvText": "Saved browser tailored CV", - "TailoredCvUpdatedAt": "2026-03-24T10:58:13.226164+01:00", - "LastReminderEmailSentAt": null, - "Messages": [], - "Attachments": [], - "Events": [], - "DaysSince": 23 - } - ], - "Correspondence": [ - { - "Id": 1, - "JobApplicationId": 1, - "From": "Company", - "Subject": "Backend Developer application update", - "Channel": "Email", - "ExternalMessageId": "browser-msg-1", - "ExternalThreadId": "browser-thread-1", - "ExternalFrom": "Maria Recruiter \u003Cmaria@acme.test\u003E", - "ExternalTo": "admin@example.com", - "Content": "We are aligning interview slots and need someone who can own the API layer.", - "Date": "2026-03-10T10:00:00+01:00" - }, - { - "Id": 2, - "JobApplicationId": 1, - "From": "Me", - "Subject": "Re: Backend Developer application update", - "Channel": "Email", - "ExternalMessageId": null, - "ExternalThreadId": null, - "ExternalFrom": null, - "ExternalTo": null, - "Content": "Hi Maria,\n\nEdited browser follow-up.\n\nThanks,\nadmin@example.com", - "Date": "2026-03-24T11:15:21.4521755" - } - ], - "Attachments": [], - "Events": [], - "Rules": { - "Id": 1, - "AppliedFollowUpDays": 14, - "AppliedGhostDays": 30, - "OfferFollowUpDays": 7, - "OfferGhostDays": 14, - "FeedbackFollowUpDays": 7, - "FeedbackGhostDays": 14 - } -} \ No newline at end of file diff --git a/JobTrackerApi/exports/daily_export_20260326.json b/JobTrackerApi/exports/daily_export_20260326.json deleted file mode 100644 index 7e9d2ab..0000000 --- a/JobTrackerApi/exports/daily_export_20260326.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "Version": "dailyexport.v1", - "CreatedAt": "2026-03-26T02:00:00.005823+01:00", - "Companies": [ - { - "Id": 1, - "OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c", - "Name": "Acme Browser QA", - "Location": null, - "Source": null, - "RecruiterName": "Maria Recruiter", - "RecruiterEmail": "maria@acme.test", - "RecruiterLinkedIn": null, - "LastContactedAt": "2026-03-24T11:15:21.4772436", - "NextContactAt": "2026-03-24T00:00:00", - "PipelineStage": null - } - ], - "JobApplications": [ - { - "Id": 1, - "OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c", - "JobTitle": "Backend Developer", - "CompanyId": 1, - "Company": null, - "Status": "Waiting", - "DateApplied": "2026-03-01T13:00:00+01:00", - "Location": null, - "Salary": null, - "NextAction": null, - "FollowUpAt": "2026-03-24T00:00:00", - "FeedbackRequestedAt": null, - "RecruiterMessageDraft": "Saved browser recruiter message", - "HasResume": true, - "HasCoverLetter": true, - "HasPortfolio": false, - "HasOtherAttachment": false, - "IsDeleted": false, - "DeletedAt": null, - "ResponseReceived": true, - "ResponseDate": null, - "Notes": "Browser-seeded notes\n\n\u003C\u003C\u003CAPPLICATION_ANSWER_DRAFT\u003E\u003E\u003E\nSaved browser application answer\n\u003C\u003C\u003CEND_APPLICATION_ANSWER_DRAFT\u003E\u003E\u003E", - "CoverLetterText": "Saved browser cover letter", - "JobUrl": "https://example.test/backend-developer", - "Description": "Need .NET APIs and strong stakeholder communication.", - "TranslatedDescription": null, - "DescriptionLanguage": null, - "Tags": "[\u0022.NET\u0022, \u0022APIs\u0022, \u0022Communication\u0022]", - "Deadline": null, - "ShortSummary": "Strong overlap in backend API delivery.", - "TailoredCvText": "Saved browser tailored CV", - "TailoredCvUpdatedAt": "2026-03-24T10:58:13.226164+01:00", - "LastReminderEmailSentAt": null, - "Messages": [], - "Attachments": [], - "Events": [], - "DaysSince": 24 - } - ], - "Correspondence": [ - { - "Id": 1, - "JobApplicationId": 1, - "From": "Company", - "Subject": "Backend Developer application update", - "Channel": "Email", - "ExternalMessageId": "browser-msg-1", - "ExternalThreadId": "browser-thread-1", - "ExternalFrom": "Maria Recruiter \u003Cmaria@acme.test\u003E", - "ExternalTo": "admin@example.com", - "Content": "We are aligning interview slots and need someone who can own the API layer.", - "Date": "2026-03-10T10:00:00+01:00" - }, - { - "Id": 2, - "JobApplicationId": 1, - "From": "Me", - "Subject": "Re: Backend Developer application update", - "Channel": "Email", - "ExternalMessageId": null, - "ExternalThreadId": null, - "ExternalFrom": null, - "ExternalTo": null, - "Content": "Hi Maria,\n\nEdited browser follow-up.\n\nThanks,\nadmin@example.com", - "Date": "2026-03-24T11:15:21.4521755" - } - ], - "Attachments": [], - "Events": [], - "Rules": { - "Id": 1, - "AppliedFollowUpDays": 14, - "AppliedGhostDays": 30, - "OfferFollowUpDays": 7, - "OfferGhostDays": 14, - "FeedbackFollowUpDays": 7, - "FeedbackGhostDays": 14 - } -} \ No newline at end of file diff --git a/JobTrackerApi/keys/key-9a89a42c-d2bd-4770-83fb-5930685432db.xml b/JobTrackerApi/keys/key-9a89a42c-d2bd-4770-83fb-5930685432db.xml deleted file mode 100644 index ad6c211..0000000 --- a/JobTrackerApi/keys/key-9a89a42c-d2bd-4770-83fb-5930685432db.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - 2026-03-24T09:54:28.8487759Z - 2026-03-24T09:54:28.8487759Z - 2026-06-22T09:54:28.8487759Z - - - - - - - LXbXqbpiEXn0OM6fr/TuXDBcZd83DvOInTI09PGZRr1Z20LQCD/PUKF1oo9UwC4O1VgK3wA//yxH9PPCIPzEaw== - - - - \ No newline at end of file diff --git a/keys/key-b3ca4672-1056-4ac2-ba47-0432608a4115.xml b/keys/key-b3ca4672-1056-4ac2-ba47-0432608a4115.xml deleted file mode 100644 index 4c53c94..0000000 --- a/keys/key-b3ca4672-1056-4ac2-ba47-0432608a4115.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - 2026-03-27T07:52:25.0540436Z - 2026-03-27T07:52:25.0540436Z - 2026-06-25T07:52:25.0540436Z - - - - - - - mfglwuKFrMSiWcbTVDEbPYM0eGAqlsOMHe89hNOsZUguUMMiusdx3m3ZQJvxnBCxeXte6OS+zvpZl3tIizvgHg== - - - - \ No newline at end of file From c38295d86942a6645ed154875fc923e273694eb3 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 2 Jul 2026 21:24:03 +0200 Subject: [PATCH 05/27] docs: add system overview, product research, and roadmap Phase 1-3 deliverables: full architecture/security/tech-debt map, 2026 market research with feature matrix, and tiered execution roadmap. Co-Authored-By: Claude Fable 5 --- docs/PRODUCT_RESEARCH.md | 121 ++++++++++++++++ docs/ROADMAP.md | 75 ++++++++++ docs/SYSTEM_OVERVIEW.md | 293 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 489 insertions(+) create mode 100644 docs/PRODUCT_RESEARCH.md create mode 100644 docs/ROADMAP.md create mode 100644 docs/SYSTEM_OVERVIEW.md diff --git a/docs/PRODUCT_RESEARCH.md b/docs/PRODUCT_RESEARCH.md new file mode 100644 index 0000000..b65faa0 --- /dev/null +++ b/docs/PRODUCT_RESEARCH.md @@ -0,0 +1,121 @@ +# PRODUCT_RESEARCH.md — Job Application Tracking Market (2026) + +> Phase 2 deliverable. Research conducted 2026-07-02 via web sources (linked throughout). +> Purpose: position Jobbjakt against the market and rank the features worth building next. + +--- + +## 1. Market landscape + +The market splits into five clusters: + +| Cluster | Representatives | Model | +|---|---|---| +| **Tracker-first + AI resume** | [Teal](https://www.tealhq.com/), [Huntr](https://huntr.co/pricing), JibberJobber | Freemium SaaS; premium $29–40/mo | +| **Autofill / volume** | [Simplify](https://simplify.jobs/job-application-tracker) (autofill), [LazyApply](https://lazyapply.com/) ($99–999/yr), LoopCV (auto-apply) | Extension-centric | +| **Matching + copilot** | [Jobright](https://jobright.ai/blog/teal-review-2026-walkthrough-alternatives-and-faqs/) | AI job matching, resume tailoring, autofill | +| **Resume/ATS optimization** | [Jobscan](https://www.jobscan.co/) ($49.95/mo!), Resume Worded, Rezi | Match-score per job description | +| **Self-hosted / privacy** | [JobSync](https://github.com/Gsync/jobsync), [CareerSync](https://github.com/Tomiwajin/CareerSync), [career-ops](https://career-ops.org/), various [GitHub projects](https://github.com/topics/job-application-tracker) | OSS, local-first, often Ollama-based | +| **Email auto-tracking** | [Trackr](https://www.trackrjobs.com/), [G-Track](https://jobtrack-ai.com/gmail-job-tracker), Gmail [Chrome extensions](https://chromewebstore.google.com/detail/gmail-job-application-tra/lkpjngmdfncejiomkofogfdoppgifmkh) | Inbox scanning → status updates | + +### Competitor snapshots + +**Teal** — market leader for tracker+resume. Free: unlimited tracking, Chrome extension (50+ job boards), kanban (Saved/Applied/Interview/Offer/Rejected), 10 ATS templates, contact manager, ATS score (15 checks). Premium ($9/wk, $29/mo, [$79/qtr](https://www.tealhq.com/pricing)): keyword match scoring, AI bullets/cover letters, analytics. Cons reported: [billing-after-cancellation complaints, generic/hallucinating AI content, ATS failures on two-column templates](https://resumehog.com/blog/posts/teal-hq-review-april-2026-is-the-job-tracker-worth-your-time.html), [high-maintenance workflow, overwhelming UI, poor support](https://resumejudge.com/blog/tealhq-review/), no automation. + +**Huntr** — best visual kanban + CRM layer. Free: 100 tracked jobs cap, unlimited base resumes, basic scoring. [Pro $40/mo](https://huntr.co/pricing): AI tailored resumes, unlimited cover letters, advanced matching/insights. 4.9★ extension (clip from any site + autofill). Cons: [must rebuild resume inside their builder, plain templates, free plan stops being useful fast](https://resumejudge.com/blog/huntr-review/), online-only. + +**Simplify** — free autofill extension for 100+ ATS portals (Workday, Greenhouse, iCIMS), real-time keyword flagging, pipeline tracking. Execution-focused, light on CRM depth. + +**Jobscan** — per-job resume match score (1–100, 30+ checks, "aim ≥75%"), cover-letter optimization report. Expensive ($49.95/mo). This single feature is the most-cited reason people pay for job-search tools. + +**Email auto-trackers** (Trackr, G-Track, extensions) — scan Gmail, AI-classify (Applied/Next step/Rejected/Offer), auto-update statuses, apply labels. This is rapidly becoming table stakes; users love "zero manual data entry". + +**Self-hosted OSS** (JobSync, CareerSync, career-ops) — privacy pitch ("no cloud, no telemetry, no account"), Ollama/local-LLM parsing, but all are far less complete than Jobbjakt: mostly CRUD + basic AI, no CV pipeline, no correspondence CRM, no rules engine. + +### Standard vs premium features across the market + +- **Table stakes (free everywhere):** kanban board, status stages, notes, basic contact tracking, browser clipper, export. +- **Premium (what people pay for):** per-job resume↔JD **match scoring with keyword gaps**, AI tailored resumes/cover letters, analytics (response rate, funnel conversion, time-in-stage), email/interview follow-up automation, autofill at scale. +- **Emerging differentiators:** inbox auto-tracking, interview prep hubs (question banks, scheduling, calendar sync — cf. [interview scheduling tools](https://www.selectsoftwarereviews.com/buyer-guide/interview-scheduling-software)), job-match scoring against a profile, salary/offer comparison. + +### Recurring user frustrations (opportunities) + +1. **Privacy/data anxiety** — sensitive career data on VC-funded SaaS; [breach/misuse concerns](https://www.saashub.com/compare-job-tracker-by-teal-vs-huntr). Jobbjakt's core moat. +2. **Paywall fatigue** — free tiers cap exactly at the point of seriousness (Huntr's 100 jobs, Teal's AI credits, Jobscan's 5 scans/mo). +3. **AI slop** — hallucinated skills, misspelled names, generic bullets; users want AI grounded in *their* real CV (Jobbjakt's structured-CV grounding is the right architecture). +4. **Manual data entry** — retyping jobs and statuses; solved by clippers + inbox scanning. +5. **Vendor lock-in** — resumes trapped in proprietary builders (Huntr), hard exports. +6. **Tool sprawl** — tracker + Jobscan + resume builder + calendar = 4 subscriptions; users want one hub. + +--- + +## 2. Feature matrix — Jobbjakt vs market + +✅ has it · 🟡 partial · ❌ missing + +| Feature | Teal | Huntr | Simplify | OSS self-hosted | **Jobbjakt today** | +|---|---|---|---|---|---| +| Kanban pipeline | ✅ | ✅ | ✅ | 🟡 | 🟡 board view exists; status is free-text, no drag-drop canonical pipeline | +| Job capture from URL | ✅ ext | ✅ ext | ✅ ext | 🟡 | 🟡 server-side parse (Finn/NAV/LinkedIn/Jobbnorge + JSON-LD); no extension/bookmarklet | +| Inbox auto-tracking | ❌ | ❌ | 🟡 | 🟡 | ✅ **Gmail OAuth import + human review queue** (ahead of paid SaaS) | +| Contacts/recruiter CRM | ✅ | ✅ | ❌ | ❌ | 🟡 company-level only, no people entities | +| Resume/CV builder | ✅ | ✅ | 🟡 | ❌ | ✅ structured CV parse + templates + PDF export | +| Per-job tailored resume (AI) | 💰 | 💰 | 💰 | ❌ | ✅ **local-AI tailored drafts** (privacy-unique) | +| Resume↔JD match score + keyword gaps | 💰 | 💰 | 🟡 | ❌ | ❌ (handoff doc lists "missing-keyword analysis" as planned) | +| AI cover letters / messages | 💰 | 💰 | 💰 | ❌ | ✅ free, local | +| Follow-up reminders | ✅ | ✅ | 🟡 | ❌ | ✅ + rules engine (auto-ghost) — richer than most | +| Analytics dashboard (funnel, response rate, time-in-stage) | 💰 | 💰 | 🟡 | 🟡 | 🟡 basic stats endpoint only | +| Interview management (schedule, prep notes, calendar) | 🟡 | 🟡 | ❌ | ❌ | ❌ (only generic follow-up dates) | +| Calendar integration (ICS/Google) | 🟡 | 🟡 | ❌ | ❌ | ❌ | +| Salary/offer tracking & comparison | 🟡 | 🟡 | ❌ | ❌ | 🟡 salary text field only | +| Autofill applications | ❌ | ✅ | ✅ | ❌ | ❌ (out of scope — needs extension) | +| Multi-language (EN/NB) + translation | ❌ | ❌ | ❌ | ❌ | ✅ unique for Nordic market | +| Self-hosted / data ownership | ❌ | ❌ | ❌ | ✅ | ✅ | +| Mobile experience | ✅ apps | ✅ | ✅ | ❌ | 🟡 responsive-ish desktop web; no PWA | +| Export/portability | 🟡 | 🟡 | 🟡 | ✅ | ✅ JSON/CSV + daily export | + +**Position:** Jobbjakt is already **ahead of every OSS competitor** and matches or beats paid SaaS on AI drafting, Gmail import, and data ownership. Its gaps versus paid SaaS are: match scoring, canonical pipeline/kanban UX, interview & calendar layer, analytics depth, capture friction (no extension), and contact-level CRM. + +--- + +## 3. Market gap — what would make Jobbjakt significantly better than existing solutions + +> **"The private, self-hosted career hub: everything Teal+Huntr+Jobscan charge $70–90/mo for, powered by your own local AI, with your data never leaving your server."** + +No product today combines: serious tracker UX + inbox auto-tracking + local-LLM tailoring + match scoring + interview hub, self-hosted. Jobbjakt is uniquely ~60% of the way there. + +--- + +## 4. Ranked feature ideas (value × effort) + +Effort: S (<1 day) · M (1–3 days) · L (1–2 wk) · XL (>2 wk). Grounded in the Phase 1 codebase map. + +| # | Feature | User impact | Effort | Notes | +|---|---|---|---|---| +| 1 | **CV↔job match score + keyword gap analysis** (per job: score, missing keywords, section coverage; reuse structured CV JSON + existing Ollama path) | ★★★★★ — the #1 paid feature in the market, free & local here | M–L | Backend has all inputs already; add endpoint + UI panel in job workspace | +| 2 | **Canonical pipeline + drag-drop kanban** (status enum/ordering, custom stages per user, drive board/badges from it) | ★★★★★ — core daily UX; free-text status blocks analytics too | M–L | Already on README wish list; needs migration for status normalization | +| 3 | **Analytics dashboard v2** (funnel conversion, response rate, time-in-stage, weekly activity, source effectiveness) | ★★★★ — retention feature; needs #2 for clean stages | M | Data all exists in `JobEvent` history | +| 4 | **Interview hub** (interview entity: rounds, type, scheduled time, prep notes, outcome; ICS feed/export + reminders) | ★★★★ — biggest functional gap vs SaaS | L | New entity + timeline integration; ICS is cheap, Google Calendar sync later | +| 5 | **Bookmarklet / minimal browser capture** (one-click "save to Jobbjakt" using existing `jobimport/preview`) | ★★★★ — kills the biggest friction (manual entry); full extension can wait | S–M | Server parsing already exists; a bookmarklet or share-target PWA is days not weeks | +| 6 | **Contacts (people) CRM** (recruiter/hiring-manager entities linked to companies/jobs/correspondence) | ★★★ | M | Natural extension of company recruiter fields | +| 7 | **PWA pass** (installable, mobile nav polish, share-target for job URLs) | ★★★ — mobile is where users check status | M | CRA supports PWA manifest; pairs with #5 | +| 8 | **Salary/offer tracker** (structured salary min/max/currency, offer comparison view) | ★★ | S–M | Currently a free-text field | +| 9 | **Smarter inbox** (extend existing Gmail review with AI status suggestions: "this looks like a rejection → move to Rejected?") | ★★★★ — compounds an existing unique strength | M | Classification via existing Ollama service | +| 10 | **Web push / digest notifications** (beyond SMTP) | ★★ | M | Needs service worker (pairs with #7) | + +Deliberately **not** recommended: auto-apply bots (ToS/ethics/quality problems, LazyApply-style tools are poorly reviewed), building a full Chrome-store extension now (high maintenance; bookmarklet first), multi-provider cloud AI (undermines the privacy moat — keep local-first with optional cloud later). + +## 5. Recommended implementation order (input to Phase 3 roadmap) + +1. **Match score + keyword gaps** (#1) — flagship differentiator, builds on freshest code (structured CV). +2. **Canonical pipeline + kanban** (#2) — unblocks analytics, fixes daily UX. +3. **Analytics v2** (#3) — quick follow-on. +4. **Bookmarklet capture** (#5) + **PWA** (#7) — friction killers. +5. **Interview hub** (#4) — biggest new surface, schedule after the above land. +6. Then #9, #6, #8, #10 by appetite. + +Engineering-health work (CI test whitelist, prod DB backups, god-controller decomposition) is tracked separately in `docs/SYSTEM_OVERVIEW.md` §15–17 and should interleave with feature work in Phase 3. + +--- + +Sources: [Prentus tracker roundup](https://prentus.com/blog/we-found-the-5-best-job-tracker-tools-on-the-market) · [ApplyArc comparison](https://applyarc.com/compare/best-job-application-trackers) · [Teal pricing](https://www.tealhq.com/pricing) · [Teal reviews (ResumeHog)](https://resumehog.com/blog/posts/teal-hq-review-april-2026-is-the-job-tracker-worth-your-time.html) · [Teal cons (ResumeJudge)](https://resumejudge.com/blog/tealhq-review/) · [Huntr pricing](https://huntr.co/pricing) · [Huntr cons (ResumeJudge)](https://resumejudge.com/blog/huntr-review/) · [Huntr vs Teal](https://huntr.co/blog/huntr-vs-teal) · [Simplify tracker](https://simplify.jobs/job-application-tracker) · [Jobright review of Teal](https://jobright.ai/blog/teal-review-2026-walkthrough-alternatives-and-faqs/) · [LazyApply](https://lazyapply.com/) · [Auto-apply tools compared](https://blog.fastapply.co/auto-apply-jobs-tools-compared-2026) · [Jobscan](https://www.jobscan.co/) · [Jobscan pricing](https://onlineatschecker.com/blog/jobscan-pricing-2026-free-plan-worth-it) · [JobSync (OSS)](https://github.com/Gsync/jobsync) · [CareerSync (OSS)](https://github.com/Tomiwajin/CareerSync) · [career-ops](https://career-ops.org/) · [Trackr](https://www.trackrjobs.com/) · [G-Track](https://jobtrack-ai.com/gmail-job-tracker) · [Gmail tracker extension](https://chromewebstore.google.com/detail/gmail-job-application-tra/lkpjngmdfncejiomkofogfdoppgifmkh) · [Interview scheduling software guide](https://www.selectsoftwarereviews.com/buyer-guide/interview-scheduling-software) · [SaaSHub Teal vs Huntr](https://www.saashub.com/compare-job-tracker-by-teal-vs-huntr) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..ea56622 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,75 @@ +# ROADMAP.md — Jobbjakt Product & Engineering Roadmap + +> Phase 3 deliverable (2026-07-02). Sources: `docs/SYSTEM_OVERVIEW.md` (Phase 1) and `docs/PRODUCT_RESEARCH.md` (Phase 2). +> Scoring: Value/Complexity/Risk on ▲ high / ● medium / ▽ low. Effort: S <1 day · M 1–3 days · L 1–2 wk · XL >2 wk. + +**North star:** the private, self-hosted career hub — the tracker UX of Huntr, the tailoring/scoring of Teal+Jobscan, powered by local AI, with data that never leaves your server. + +--- + +## Tier 0 — Quick Wins (do first; days, low risk, compounding payoff) + +| # | Item | Type | Value | Effort | Risk | Rationale | +|---|---|---|---|---|---|---| +| Q1 | **CI: run the full frontend test suite** (replace the hand-maintained 10-file whitelist with the whole suite; fix/quarantine any flaky test explicitly) | eng | ▲ | S | ▽ | New tests currently silently skipped in CI; already caused a gap once | +| Q2 | **Automated production DB backup** (scheduled SQLite `VACUUM INTO`/copy to `exports/` with retention; document restore) | eng | ▲ | S–M | ▽ | Prod currently has *no working automated backup* (backup endpoint is Windows-DPAPI-only, prod is Linux) | +| Q3 | **Repo hygiene** (delete dead root `Controller/`; remove `temp_job.json`, `temp_post_job.py`; gitignore `JobTrackerApi/CvArtifacts/`, `bin_build/`, stray artifacts; commit pending WIP fixes on a branch) | eng | ● | S | ▽ | Removes footguns before refactors; working tree currently dirty | +| Q4 | **Swagger/OpenAPI** (Swashbuckle or built-in OpenAPI, dev-only exposure) | eng | ● | S | ▽ | README endpoint list already drifts; prerequisite for a generated TS client later | +| Q5 | **Structured salary fields** (min/max/currency/period alongside the free-text field, backfill-friendly) | product | ● | S–M | ▽ | Cheap now, prerequisite for offer comparison + analytics later | + +## Tier 1 — High Value (the differentiators; next 2–4 weeks of feature work) + +| # | Item | Value | Effort | Risk | Notes | +|---|---|---|---|---|---| +| H1 | **CV↔Job match score + keyword gap analysis** — per-job score, missing keywords, section coverage; reuse `ProfileCvStructureJson` + existing Ollama path; panel in job workspace | ▲▲ | M–L | ● | The market's #1 paid feature (Jobscan $50/mo), free & local here. Flagship differentiator | +| H2 | **Canonical pipeline + drag-drop kanban** — status enum + ordering + per-user custom stages; migration normalizing existing free-text statuses; board becomes drag-drop | ▲▲ | M–L | ● | Fixes daily UX; unblocks H3; the riskiest part is the status migration (needs careful mapping + tests) | +| H3 | **Analytics dashboard v2** — funnel conversion, response rate, time-in-stage, weekly activity, source effectiveness (data already in `JobEvent`) | ▲ | M | ▽ | Depends on H2 for clean stages | +| H4 | **Gmail AI status suggestions** — extend the existing review queue: classify incoming mail (rejection/interview/offer) via local AI and suggest status moves, human-confirmed | ▲ | M | ● | Compounds an existing unique strength; keep human-in-the-loop | + +## Tier 2 — Medium Value (after Tier 1) + +| # | Item | Value | Effort | Risk | +|---|---|---|---|---| +| M1 | **Bookmarklet / PWA share-target capture** — one-click save-to-Jobbjakt reusing `jobimport/preview` | ▲ | S–M | ▽ | +| M2 | **PWA pass** — manifest, installability, mobile nav polish | ● | M | ▽ | +| M3 | **Interview hub** — interview entity (round, type, time, prep notes, outcome), timeline integration, ICS export + reminders | ▲ | L | ● | +| M4 | **Contacts (people) CRM** — recruiter/hiring-manager entities linked to companies/jobs/correspondence | ● | M | ▽ | +| M5 | **Durable CV processing queue** — DB-backed queue replacing in-memory (jobs survive restart) | ● | M | ● | +| M6 | **ProblemDetails + validation consistency** across API | ● | M | ▽ | + +## Tier 3 — Long-Term Improvements (structural; interleave carefully) + +| # | Item | Value | Effort | Risk | +|---|---|---|---|---| +| L1 | **Decompose god controllers** (`JobApplicationsController` 151 KB, `ProfileCvController` 117 KB, `GmailController` 60 KB) into feature services; extract AI prompt construction behind interfaces. Strictly behavior-preserving, test-first, one slice per PR | ▲ (maintainability) | XL | ▲ | +| L2 | **Finish the project-layout migration** — physically move linked `Models/`/`Data/`/controller/service files into real projects, retire glob-include `JobTrackerBackend` | ● | L | ● | +| L3 | **Vite migration** (CRA/react-scripts is EOL; 4 GB-heap builds) | ● | L | ● | +| L4 | **OpenAPI-generated TypeScript client** replacing hand-written `api.ts` surface | ● | M–L | ● | +| L5 | **Staging environment / deploy gate** (compose profile or second host; smoke test before prod) | ▲ (ops) | L | ● | + +## Tier 4 — Future Ideas (not scheduled) + +- Full browser extension (Chrome/Firefox store) with autofill. +- Web push notifications + weekly digest. +- Company research assistant (local AI summarizing company info). +- Offer comparison & salary analytics dashboards. +- Job feed matching from saved searches (Finn/NAV polling). +- Native mobile wrappers; CalDAV/Google Calendar two-way sync. +- Multi-instance/scale-out readiness (distributed cache/queue). + +--- + +## Recommended execution sequence (Phase 4+) + +Interleaving product and engineering so debt never blocks features: + +1. **Wave 0 (hygiene):** Q3 → Q1 → Q2 → Q4 → Q5 (each a small conventional commit on a feature branch; Q1/Q2 are the two items with real operational risk today) +2. **Wave 1 (flagship):** H1 match scoring (design doc → backend endpoint → UI panel → tests) +3. **Wave 2 (core UX):** H2 canonical pipeline/kanban, then H3 analytics +4. **Wave 3:** H4 Gmail suggestions, M1 bookmarklet, M2 PWA +5. **Wave 4:** M3 interview hub, M4 contacts, M5 durable queue +6. **Continuous:** L1 controller decomposition proceeds opportunistically — whenever a wave touches a god-controller area, extract that slice first (M6 rides along); L2–L5 scheduled after Wave 3 checkpoint. + +Phases 5–10 of the mission (bug hunt, security audit, performance, refactoring, testing, docs) run after or between waves as checkpoints; Phase 11 rules apply throughout (feature branches, conventional commits, full test suite before commit, no auto-merge to main). + +**Explicitly deprioritized:** auto-apply automation (quality/ToS problems), cloud AI providers (undermines privacy moat), Chrome-store extension before the bookmarklet proves demand. diff --git a/docs/SYSTEM_OVERVIEW.md b/docs/SYSTEM_OVERVIEW.md new file mode 100644 index 0000000..6661dec --- /dev/null +++ b/docs/SYSTEM_OVERVIEW.md @@ -0,0 +1,293 @@ +# Jobbjakt (Job Tracker) — System Overview + +> Phase 1 deliverable: full-system map produced before any code changes. +> Last updated: 2026-07-02. Verified against commit `eea327e1` plus local working-tree changes. + +--- + +## 1. What the product is + +Jobbjakt is a self-hosted, multi-user job application tracking platform with heavy AI assistance: + +- Track job applications end-to-end (status pipeline, follow-ups, deadlines, salary, tags, notes). +- Company/recruiter CRM (pipeline stage, contact dates, recruiter details). +- Correspondence log per application, including **Gmail OAuth import with review workflow**. +- Attachments per application with purpose metadata and AI-inclusion toggles. +- **CV platform**: upload → OCR/text extraction → structured CV parsing (Ollama-assisted block classification) → per-job tailored CV drafts → templated PDF export via Playwright. +- AI drafts: cover letters, recruiter messages, follow-up drafts, job description summaries, translation (LibreTranslate optional). +- Rules engine (auto-ghosting, follow-up "needs attention"), reminder emails, daily JSON export, history/event trail, encrypted backup (Windows/DPAPI). +- Admin surface: user management, audit log, system readiness page. +- Deployed to production at `https://jobs.cesnimda.uk` via Gitea Actions → SSH → Docker Compose. + +--- + +## 2. Architecture overview + +```mermaid +flowchart LR + subgraph Client + UI[React 19 SPA
MUI 7, react-router 6
CRA/react-scripts] + end + + subgraph Frontend container + NGINX[nginx 1.29-alpine
serves build + proxies /api] + end + + subgraph Backend container + API[ASP.NET Core net9.0 API
JobTrackerApi host] + BG[Hosted services:
Rules, FollowUpReminder,
DailyExport, JobEnrichment,
SummarizerProbe, CvProcessing] + DB[(SQLite default
or MariaDB/MySQL)] + FS[/Data root:
Attachments, CvArtifacts,
exports, DP keys/] + end + + subgraph AI stack + AISVC[FastAPI ai-service :8001
distilbart summarizer,
OCR pytesseract/PyMuPDF,
docx/pdf extraction] + OLLAMA[Ollama :11434
qwen2.5:7b
CV classification + rewrite] + end + + EXT1[Google OAuth / Gmail API] + EXT2[Job sites: Finn, NAV,
LinkedIn, Jobbnorge] + EXT3[SMTP - Gmail app password] + EXT4[LibreTranslate optional] + + UI --> NGINX --> API + API --> DB + API --> FS + API --> AISVC --> OLLAMA + API --> EXT1 + API --> EXT2 + API --> EXT3 + API --> EXT4 + BG --> DB +``` + +### Solution layout (unusual — read this first) + +| Project | Role | +|---|---| +| `JobTrackerApi/` | Web **host** only: `Program.cs`, appsettings, migrations, Dockerfile. Its csproj **excludes** `Controllers/**` and `Services/**` from its own compilation. | +| `JobTrackerBackend/` | "Transitional shared-backend" **library** that compiles, via `` links, the files physically located in `../Data`, `../Models`, `../JobTrackerApi/Controllers`, `../JobTrackerApi/Services`. Exists so tests can reference controllers/services without the web-entry project. | +| `JobTrackerApi.Tests/` | xUnit test project (~20 test classes incl. authorization/hostile-fixture tests). | +| `Models/`, `Data/` (repo root) | The *real* EF models and `JobTrackerContext`, compiled into JobTrackerBackend. | +| `Controller/` (repo root) | **Legacy stub controllers (~1 KB each) — dead code**, not referenced by any csproj. | +| `job-tracker-ui/` | React SPA. | +| `tools/summarizer/` | FastAPI AI service (own Dockerfile, pytest tests). | +| `deploy/`, `.gitea/workflows/` | Prod deploy script + CI/CD pipeline. | +| `docs/` | Session handoffs, security assessments (M013–M015), UAT notes. | + +--- + +## 3. Technology stack + +**Backend**: ASP.NET Core net9.0, EF Core 9 (SQLite default; Pomelo MySQL/MariaDB switchable via `Database:Provider`), ASP.NET Identity Core (users/roles), JWT bearer auth (local + Google policy scheme), built-in RateLimiter, DataProtection (file-system keys), Playwright (CV PDF export). + +**Frontend**: React 19, TypeScript 4.9, MUI 7 (+ x-data-grid, x-date-pickers, lab), axios, react-router-dom 6, @tanstack/react-table, CRA `react-scripts` 5 (build needs `--max-old-space-size=4096`), i18n EN + NB (custom provider), Jest/RTL tests. + +**AI**: FastAPI + transformers (`sshleifer/distilbart-cnn-12-6`) for summaries; pytesseract/PyMuPDF/pypdf/python-docx for extraction/OCR; Ollama (`qwen2.5:7b`) for CV block classification and rewrite paths; TTL cache. + +**Infra**: Docker Compose (4 services: backend, frontend/nginx, ai-service, ollama w/ GPU), Gitea Actions CI (build + backend tests + selected frontend tests + frontend build) → SSH deploy → `deploy/deploy.sh` on the prod host, external `jobtracker_shared` network. + +--- + +## 4. Authentication & authorization + +- **Smart policy scheme**: inspects the bearer token issuer — Google-issued ID tokens (`accounts.google.com`) route to the `google` JWT handler (validated against `Auth:GoogleClientId`); everything else routes to `local` JWT (symmetric key `Auth:JwtKey`, issuer/audience validated, 2-min clock skew). +- **Cookie session support**: local handler also reads the session cookie (`AuthSessionOptions.SessionCookieName`); **CSRF double-submit** middleware enforces cookie+header match for all mutating requests when a session cookie is present (login/register/reset/csrf endpoints exempt). +- `Auth:Require=true` sets a fallback authorize-all policy (prod compose sets it). Dev without a JWT key generates an ephemeral key + warning; **fails closed** if auth required but no key. +- Local tokens **must** carry a subject claim (`LocalAuthIdentity`), enforced in `OnTokenValidated` — hardened after finding M013-2. +- **Multi-tenancy**: every tenant entity carries `OwnerUserId`; `JobTrackerContext` applies global query filters `CurrentUserId != null && OwnerUserId == CurrentUserId` (deny-on-null). Correspondence/JobEvents/CV entities filter through their parent's owner. +- Roles via ASP.NET Identity: admin-only controllers (`UsersController`, `AdminAuditController`, `AdminSystemController`). +- Password policy: min 8, digit + lowercase required. Password reset via emailed token (SMTP required). Registration disabled by default. +- Rate limiting: `auth-login` (10/5 min/IP) and `auth-email` (5/15 min/IP) fixed-window policies. + +--- + +## 5. Database schema (EF Core, 8 migrations) + +```mermaid +erDiagram + ApplicationUser ||--o{ Company : owns + ApplicationUser ||--o{ JobApplication : owns + ApplicationUser ||--o| UserRuleSettings : has + ApplicationUser ||--o{ GmailConnection : has + ApplicationUser ||--o{ CvUploadArtifact : owns + ApplicationUser ||--o{ CvExtractionRun : owns + Company ||--o{ JobApplication : "has jobs" + JobApplication ||--o{ Correspondence : messages + JobApplication ||--o{ Attachment : attachments + JobApplication ||--o{ JobEvent : events + JobApplication ||--o| TailoredCvDraft : "1:1 draft" + CvUploadArtifact ||--o{ CvExtractionRun : "source of" + ApplicationUser ||--o{ GmailReviewDecision : decides +``` + +Key notes: + +- `ApplicationUser` (IdentityUser) also stores profile CV text, **structured CV JSON** (`ProfileCvStructureJson`), avatar data-URL, Google link info, current CV artifact/run pointers. +- `JobApplication`: status string (default "Applied"), soft delete (`IsDeleted`/`DeletedAt`), tags as JSON string, imported description + translation, persisted `ShortSummary`, tailored CV text, reminder bookkeeping. Cascade deletes to messages/attachments/events/draft. +- `RuleSettings` (global, seeded Id=1) + per-user `UserRuleSettings`. +- `SystemEmailSettings`: DB-stored SMTP override (resolved by `EmailSettingsResolver`). +- Indexes: `OwnerUserId` on Company/JobApplication/GmailConnection; composite `(OwnerUserId, UploadedAtUtc)`, `(OwnerUserId, StartedAtUtc)`, unique `(OwnerUserId, JobApplicationId)` on draft, unique `(OwnerUserId, GmailAddress)`. +- SQLite file lives at `DataRoot/jobtracker.db` (WAL mode); migrations applied automatically at startup (`StartupInitializationExtensions`, 62 KB — also seeds admin, creates Identity tables where `dotnet ef` unavailable, ignores `PendingModelChangesWarning`). + +--- + +## 6. API surface (all under `/api`, ~15 controllers) + +| Controller | Highlights | +|---|---| +| `JobApplicationsController` (**151 KB!**) | CRUD, paging/filtering/sorting, board, reminders, stats, history, unified timeline, status/follow-up PATCH, soft delete/restore, **plus** AI surface: application package material, follow-up drafts, cover-letter/recruiter drafts ("Maria" drafts), workflow signals. | +| `ProfileCvController` (**117 KB**) | CV upload artifacts, extraction runs, structure parsing, rebuild/improve, tailored CV generation via Ollama rewrite, template rendering + Playwright PDF preview/export, benchmark corpus harness. | +| `GmailController` (**60 KB**) | OAuth connect/callback, sync, message review queue, import decisions, job matching. | +| `AuthController` (22 KB) | login/register/me/config, Google exchange, password reset request/reset, session cookie + CSRF endpoints. | +| `CompaniesController` | CRUD, idempotent create by name, recruiter/pipeline fields. | +| `CorrespondenceController` | per-job messages CRUD. | +| `AttachmentsController` | multipart upload to disk, download, rename, delete, purpose/AI-inclusion metadata. | +| `RulesController` | global + per-user rule settings, clamped. | +| `ExportController` | JSON/CSV export. | +| `BackupController` | DPAPI-encrypted backup (Windows only). | +| `JobImportController` | URL preview via plugin parsers (SSRF-hardened). | +| `UsersController`, `AdminAuditController`, `AdminSystemController` | admin: user/role management, audit trail, system readiness (DB/Gmail/AI). | +| `ClientErrorsController` | frontend error intake → logs. | + +No OpenAPI/Swagger is wired up; the README is the de-facto API doc (already drifting). + +--- + +## 7. Background services (6 hosted services) + +| Service | Function | +|---|---| +| `RulesHostedService` → `RulesEngine` | periodic auto-transitions (e.g., → Ghosted) from rule settings | +| `FollowUpReminderHostedService` | reminder emails for due/upcoming follow-ups (dedup via `LastReminderEmailSentAt`) | +| `DailyExportHostedService` | daily JSON export at configured local hour | +| `JobEnrichmentHostedService` | backfills summaries/enrichment for jobs | +| `SummarizerProbeHostedService` | probes AI service readiness | +| `CvProcessingHostedService` + `CvProcessingQueue` | in-memory queue for CV extraction/processing jobs | + +All state is in-process (`IMemoryCache`, in-memory queue) — single-instance assumption; no distributed locks; queue contents lost on restart. + +--- + +## 8. AI pipeline (data flow) + +1. **Job import**: URL → plugin parse (Finn/NAV/LinkedIn/Jobbnorge or universal JSON-LD parser) → optional LibreTranslate → language detect + skill tagging → preview → user accepts → stored on `JobApplication`. +2. **Summaries**: API → `SummarizerService` (31 KB) → FastAPI `/summarize` (distilbart, TTL-cached, GPU-if-available) → persisted `ShortSummary`. +3. **CV ingest**: upload (PDF/DOCX/image ≤ 8 MB) → FastAPI extract/OCR → block classification (Ollama-assisted, `CvAiClassifier`/`CvAiNormalizer`) → `ProfileCvStructureJson` on user. +4. **Tailoring**: job description + structured CV sections → Ollama rewrite path (recent commits: clamped lengths, hardened diagnostics) → `TailoredCvDraft` (JSON blocks) → `CvTemplateRenderer` (25 KB, template carousel) → Playwright → PDF. +5. **Drafts**: cover letter / recruiter message / follow-up drafts generated per job with attachment-aware context selection. + +Degradation: if AI service or Ollama is down, core tracking still works (probe service + "AI is not a deploy gate" in CI). + +--- + +## 9. Email + +- `SmtpEmailSender` with `EmailSettingsResolver`: config from env/appsettings **or** DB-stored `SystemEmailSettings` (admin-editable). +- Uses Gmail SMTP + app password in prod. Flows: password reset, follow-up reminders. `App:PublicBaseUrl` builds links. + +--- + +## 10. Configuration & secrets + +- `.env` (git-ignored) → docker-compose env → ASP.NET config. `.env.example` documents the shape. Real secrets currently present in local `.env` (JWT key, admin password, SMTP app password, Google client secret). +- `appsettings.Development.json` contains only `CHANGE_ME_*` placeholders (good). +- Key knobs: `Database:Provider`, `ConnectionStrings:JobTracker`, `Data:Root`, `Cors:Origins`, `Ai:BaseUrl`, `Auth:*`, `Email:*`, `Exports:*`, `Translation:*`, `App:PublicBaseUrl`, `HttpsRedirection:*` (TLS terminated at reverse proxy; HSTS/redirect off in-container). +- `ProductionConfigTests.cs` exists to guard prod config shape. + +--- + +## 11. Build, CI/CD, deployment + +- **CI** (`.gitea/workflows/ci-deploy.yml`): on PR + push-to-main → build backend (Release), run backend tests, `npm ci`, run an **explicit whitelist of 10 frontend test files** (not the whole suite), build frontend. +- **Deploy** (push to main only): SSH to prod host → `git reset --hard ` in `/opt/job-tracker/app` → `deploy/deploy.sh` (docker compose build/up with retry/cache-prune fallbacks) → verify containers; AI service health is non-blocking. +- Frontend Dockerfile: node build stage → nginx 1.29-alpine (working-tree bump from 1.27 pending commit); nginx proxies `/api` to backend. +- No staging environment; deploys go straight to prod after CI. + +--- + +## 12. Testing strategy + +- **Backend**: xUnit integration-style tests via `TestHostFactory`; notable coverage: authorization (`JobApplicationsAuthorizationTests`, `OwnershipGuardTests`, hostile fixture DB project), auth/system, Gmail, CV corpus harness, summarizer, SQLite migration helper, production config. +- **Frontend**: ~20 Jest/RTL test files (workspace flows, Gmail review, login, admin, attachments, drafts, trust-loop e2e-ish component tests). CI runs only the whitelisted subset. +- **AI service**: pytest (`tools/summarizer/tests/test_app.py`). +- No true end-to-end browser tests; no load/perf tests. + +--- + +## 13. Logging & error handling + +- Console/debug logging; custom middleware logs every request (method, path, status, ms, traceId, sub claim). Unhandled exceptions logged then rethrown (500). +- Client errors POSTed to `/api/client-errors` and logged server-side; React `ErrorBoundary` + route error page in UI. +- No structured sink (Seq/OTLP), no log rotation policy in-app (container stdout), no correlation to frontend errorIds beyond log text, no ProblemDetails standardization. + +--- + +## 14. Security posture (current) + +Strong points (much already hardened via M013–M015 adversarial assessments in `docs/security-assessments/`): + +- SSRF on job import **fixed & retested** (DNS resolution check, private/loopback/link-local rejection, redirects disabled). +- Subjectless-JWT / owner-filter bypass **fixed & retested** (fail-closed identity, deny-on-null query filters). +- Cross-user job history leak fixed (`81196374`); authorization replay findings recorded (M015). +- CSRF double-submit for cookie sessions; CORS allowlist; rate-limited login/email endpoints; ephemeral JWT key refused when auth required; Identity password hashing (PBKDF2); DataProtection keys persisted outside repo runtime path. + +Open questions / watch areas (to verify in Phase 6): + +- `AllowCredentials()` combined with configurable `Cors:Origins="*"` wildcard mode (SetIsOriginAllowed(true) + credentials) — dangerous if ever enabled. +- Attachment upload: file-type/size limits, path handling, content-type on download need re-audit. +- Avatar stored as data-URL on user record (size/XSS considerations). +- Gmail OAuth token storage encryption at rest; scopes; audit of `GmailController` (60 KB). +- Global rate limiting only on 2 auth policies — AI/expensive endpoints unthrottled. +- Backup endpoint Windows-only DPAPI — silently unavailable on Linux prod. +- Dependency freshness (axios, react-scripts 5/CRA is deprecated upstream; transformers/torch pinning). +- Secrets present in local `.env` (expected, git-ignored) — confirm no history leaks. + +--- + +## 15. Technical debt report + +1. **God controllers**: `JobApplicationsController` (151 KB), `ProfileCvController` (117 KB), `GmailController` (60 KB), `StartupInitializationExtensions` (62 KB). Massive single files mixing HTTP, business logic, AI prompt construction, and persistence. Highest-leverage refactor target — but high risk, needs test cover first. +2. **Transitional project layout**: `JobTrackerBackend` compiles files it doesn't own via glob includes; root `Models/`/`Data/` folders; **dead** root `Controller/` folder; `JobTrackerBackend/bin`+`obj` artifacts and `JobTrackerApi/jobtracker.db` + `bin_build/`, `CvArtifacts/`, `exports/`, `keys/` polluting the repo/working tree. `.gitignore` needs review. +3. **CI runs a hand-maintained subset** of frontend tests — new test files silently not run (already bit them once; `profile-page.test.tsx` had to be added manually). +4. **CRA/react-scripts 5** is EOL-ish, slow builds (needs 4 GB heap), TS 4.9. Vite migration is the obvious path (medium effort). +5. **Naming drift**: `Summarizer*` vs `AiService*`; "Jobbjakt" vs "Job Tracker" branding split; EN/NB translation consistency flagged in handoff doc. +6. No OpenAPI; README endpoint list already drifts from code (e.g., Gmail/profile/admin endpoints missing there). +7. In-memory queue/cache single-instance coupling undocumented. +8. Root-level clutter: `temp_job.json`, `temp_post_job.py`, `todo jobtracker.txt`, `test/`, `tmp/`, `vendor/`, `.venv/`. +9. `DaysSince` compares `DateTime.UtcNow` with `.Days` truncation — timezone/UX edge cases; status is a free string, no canonical pipeline enum (README itself lists this as a wanted improvement). +10. Windows-only backup path. + +--- + +## 16. Areas of concern + +- **Single point of data**: SQLite in a Docker volume; backups are manual/Windows-only; no automated off-host backup. +- **Deploy risk**: `git reset --hard` + straight-to-prod with no staging and non-exhaustive CI test coverage. +- **AI coupling**: prompt logic buried in controllers makes model/provider changes and testing hard. +- **Restart data loss**: queued CV processing jobs are lost on restart (in-memory queue). +- **Uncommitted working tree**: 3 modified files (Dockerfile nginx bump, `useViewResource` stale-closure fix, handoff doc) + untracked `scripts/start-ollama-cv.ps1` and a stray `JobTrackerApi/CvArtifacts/` data folder. + +--- + +## 17. Opportunities for improvement (input to Phase 2/3) + +Product (initial hypotheses, to be validated by market research): + +- Canonical pipeline model + customizable Kanban stages (already on README wish list). +- Interview scheduling/prep hub (calendar integration, prep notes, question banks). +- Salary/offer comparison and analytics dashboards (funnel conversion, response rates, time-in-stage). +- Browser extension / bookmarklet for one-click job capture (plugins already exist server-side). +- Saved searches/views, full-text search, date-range and tag filters. +- Notifications beyond email (web push, digest). +- Contact-level recruiter CRM (people, not just companies). +- Mobile-friendly PWA pass. + +Engineering: + +- Swagger/OpenAPI + generated TS client; ProblemDetails everywhere. +- Split god controllers into feature services; move AI prompting behind interfaces. +- Run full frontend test suite in CI (`npm test -- --watchAll=false` without whitelist) once flaky tests are addressed; add `dotnet format`/eslint gates. +- Vite migration; dependency refresh. +- Durable job queue (DB-backed) for CV processing; automated DB backup job. +- Repo hygiene: delete dead `Controller/`, ignore build artifacts, remove committed DB files. From e352aaeaac7b5fa5fcd8b3458d872487def1c07c Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 2 Jul 2026 21:30:18 +0200 Subject: [PATCH 06/27] fix(ui): avoid stale load closure in useViewResource reload Keep the latest load callback in a ref so reload() always invokes the current fetcher without changing its own identity on every render. Reduces full-suite test failures from 5 to 3 (remaining are pre-existing). Co-Authored-By: Claude Fable 5 --- job-tracker-ui/src/hooks/useViewResource.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/job-tracker-ui/src/hooks/useViewResource.ts b/job-tracker-ui/src/hooks/useViewResource.ts index de6aee9..720dd78 100644 --- a/job-tracker-ui/src/hooks/useViewResource.ts +++ b/job-tracker-ui/src/hooks/useViewResource.ts @@ -65,11 +65,16 @@ export function useViewResource( const [hasLoaded, setHasLoaded] = useState(false); const [error, setError] = useState(null); const hasLoadedRef = useRef(hasLoaded); + const loadRef = useRef(load); useEffect(() => { hasLoadedRef.current = hasLoaded; }, [hasLoaded]); + useEffect(() => { + loadRef.current = load; + }, [load]); + const reload = useCallback(async () => { if (!enabled) return; @@ -77,7 +82,7 @@ export function useViewResource( setLoading(!alreadyLoaded); setRefreshing(alreadyLoaded); try { - const next = await load(); + const next = await loadRef.current(); setData(next); setError(null); setHasLoaded(true); @@ -88,7 +93,7 @@ export function useViewResource( setLoading(false); setRefreshing(false); } - }, [enabled, errorMessage, load]); + }, [enabled, errorMessage]); useEffect(() => { if (!enabled) { From 999d6e05e7b201dfcb0f3976108f00266e45e4bd Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 2 Jul 2026 21:49:58 +0200 Subject: [PATCH 07/27] feat: automated daily SQLite database backups with retention New DatabaseBackupHostedService + SqliteDatabaseBackupRunner: - daily VACUUM INTO snapshot to /backups (safe with WAL) - catch-up backup at startup when none exists from the last 24h - retention pruning (Backups:RetainCount, default 14) - warns and stays idle on MySQL/MariaDB where external backups apply Production previously had no automated database backup on Linux (the /api/backup endpoint is Windows-DPAPI-only). Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + .../DatabaseBackupRunnerTests.cs | 97 +++++++++++++++ JobTrackerApi/Program.cs | 2 + .../Services/DatabaseBackupHostedService.cs | 84 +++++++++++++ .../Services/DatabaseBackupRunner.cs | 110 ++++++++++++++++++ README.md | 4 + 6 files changed, 298 insertions(+) create mode 100644 JobTrackerApi.Tests/DatabaseBackupRunnerTests.cs create mode 100644 JobTrackerApi/Services/DatabaseBackupHostedService.cs create mode 100644 JobTrackerApi/Services/DatabaseBackupRunner.cs diff --git a/.gitignore b/.gitignore index 48d6b05..117a76c 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ tmp/ # Runtime data that must never be committed (DataProtection keys, exports, CV artifacts) keys/ +backups/ JobTrackerApi/exports/ JobTrackerApi/CvArtifacts/ JobTrackerApi/CvExports/ diff --git a/JobTrackerApi.Tests/DatabaseBackupRunnerTests.cs b/JobTrackerApi.Tests/DatabaseBackupRunnerTests.cs new file mode 100644 index 0000000..f55b764 --- /dev/null +++ b/JobTrackerApi.Tests/DatabaseBackupRunnerTests.cs @@ -0,0 +1,97 @@ +using JobTrackerApi.Services; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class DatabaseBackupRunnerTests : IDisposable +{ + private readonly string _root; + + public DatabaseBackupRunnerTests() + { + _root = Path.Combine(Path.GetTempPath(), $"jt-backup-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + try { Directory.Delete(_root, recursive: true); } catch (IOException) { } + } + + private string CreateSourceDb(out string connectionString) + { + var dbPath = Path.Combine(_root, "source.db"); + connectionString = $"Data Source={dbPath}"; + using var connection = new SqliteConnection(connectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TABLE Sample (Id INTEGER PRIMARY KEY, Name TEXT); INSERT INTO Sample (Name) VALUES ('alpha'), ('beta');"; + command.ExecuteNonQuery(); + return dbPath; + } + + private SqliteDatabaseBackupRunner CreateRunner(string connectionString, int retainCount = 14) + => new(connectionString, Path.Combine(_root, "backups"), retainCount, NullLogger.Instance); + + [Fact] + public async Task RunOnce_creates_a_restorable_backup_file() + { + CreateSourceDb(out var connectionString); + var runner = CreateRunner(connectionString); + + var backupPath = await runner.RunOnceAsync(CancellationToken.None); + + Assert.NotNull(backupPath); + Assert.True(File.Exists(backupPath)); + + await using var verify = new SqliteConnection($"Data Source={backupPath}"); + await verify.OpenAsync(); + await using var count = verify.CreateCommand(); + count.CommandText = "SELECT COUNT(*) FROM Sample"; + Assert.Equal(2L, (long)(await count.ExecuteScalarAsync())!); + } + + [Fact] + public async Task RunOnce_prunes_backups_beyond_retention() + { + CreateSourceDb(out var connectionString); + var runner = CreateRunner(connectionString, retainCount: 2); + var backupsRoot = runner.BackupsRoot; + Directory.CreateDirectory(backupsRoot); + + for (var i = 0; i < 3; i++) + { + var stale = Path.Combine(backupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}stale{i}.db"); + File.WriteAllText(stale, "stale"); + File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddDays(-10 - i)); + } + + await runner.RunOnceAsync(CancellationToken.None); + + var remaining = Directory.GetFiles(backupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}*.db"); + Assert.Equal(2, remaining.Length); + Assert.Contains(remaining, f => Path.GetFileName(f).Contains("stale0")); + } + + [Fact] + public void Latest_backup_timestamp_reflects_newest_file() + { + CreateSourceDb(out var connectionString); + var runner = CreateRunner(connectionString); + + Assert.Null(runner.GetLatestBackupUtc()); + + Directory.CreateDirectory(runner.BackupsRoot); + var file = Path.Combine(runner.BackupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}x.db"); + File.WriteAllText(file, "x"); + var stamp = DateTime.UtcNow.AddHours(-3); + File.SetLastWriteTimeUtc(file, stamp); + + var latest = runner.GetLatestBackupUtc(); + Assert.NotNull(latest); + Assert.True(Math.Abs((latest!.Value - stamp).TotalSeconds) < 2); + } +} diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index a76b189..6a36ce0 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -128,6 +128,8 @@ Directory.CreateDirectory(dataProtectionKeysPath); builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath)) .SetApplicationName("JobTracker"); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/JobTrackerApi/Services/DatabaseBackupHostedService.cs b/JobTrackerApi/Services/DatabaseBackupHostedService.cs new file mode 100644 index 0000000..83c1392 --- /dev/null +++ b/JobTrackerApi/Services/DatabaseBackupHostedService.cs @@ -0,0 +1,84 @@ +namespace JobTrackerApi.Services +{ + public sealed class DatabaseBackupHostedService : BackgroundService + { + private readonly IDatabaseBackupRunner _runner; + private readonly ILogger _logger; + private readonly IConfiguration _cfg; + private readonly IStartupReadiness _startupReadiness; + + public DatabaseBackupHostedService( + IDatabaseBackupRunner runner, + ILogger logger, + IConfiguration cfg, + IStartupReadiness startupReadiness) + { + _runner = runner; + _logger = logger; + _cfg = cfg; + _startupReadiness = startupReadiness; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await _startupReadiness.WaitUntilReadyAsync(stoppingToken); + + if (!_cfg.GetValue("Backups:Enabled", true)) + { + _logger.LogInformation("Automated database backups disabled (Backups:Enabled=false)."); + return; + } + + if (!_runner.IsSupported) + { + _logger.LogWarning("Automated database backups are unavailable for the configured provider. Configure external backups for MySQL/MariaDB."); + return; + } + + var hour = _cfg.GetValue("Backups:HourLocal", 3); + if (hour < 0 || hour > 23) hour = 3; + + // Catch-up: guarantee at least one recent backup exists even if the + // process never stays up long enough to reach the scheduled hour. + var latest = _runner.GetLatestBackupUtc(); + if (latest is null || latest < DateTime.UtcNow.AddHours(-24)) + { + await TryBackupAsync(stoppingToken); + } + + while (!stoppingToken.IsCancellationRequested) + { + var now = DateTime.Now; + var next = new DateTime(now.Year, now.Month, now.Day, hour, 0, 0); + if (next <= now) next = next.AddDays(1); + + _logger.LogInformation("Next database backup scheduled at {Next}.", next); + try + { + await Task.Delay(next - now, stoppingToken); + } + catch (TaskCanceledException) + { + break; + } + + await TryBackupAsync(stoppingToken); + } + } + + private async Task TryBackupAsync(CancellationToken ct) + { + try + { + await _runner.RunOnceAsync(ct); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Database backup failed."); + } + } + } +} diff --git a/JobTrackerApi/Services/DatabaseBackupRunner.cs b/JobTrackerApi/Services/DatabaseBackupRunner.cs new file mode 100644 index 0000000..894db99 --- /dev/null +++ b/JobTrackerApi/Services/DatabaseBackupRunner.cs @@ -0,0 +1,110 @@ +using Microsoft.Data.Sqlite; + +namespace JobTrackerApi.Services +{ + public interface IDatabaseBackupRunner + { + string BackupsRoot { get; } + bool IsSupported { get; } + + /// Creates one backup file and prunes old ones. Returns the backup path, or null when unsupported. + Task RunOnceAsync(CancellationToken ct); + + DateTime? GetLatestBackupUtc(); + } + + public sealed class SqliteDatabaseBackupRunner : IDatabaseBackupRunner + { + public const string BackupFilePrefix = "jobtracker_backup_"; + + private readonly ILogger _logger; + private readonly string _connectionString; + private readonly int _retainCount; + + public string BackupsRoot { get; } + public bool IsSupported { get; } + + public SqliteDatabaseBackupRunner(IConfiguration cfg, AppPaths paths, ILogger logger) + { + _logger = logger; + + var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant(); + var cs = cfg.GetConnectionString("JobTracker"); + if (string.IsNullOrWhiteSpace(cs)) + { + cs = $"Data Source={paths.GetDbPath()}"; + provider = "sqlite"; + } + + _connectionString = cs; + IsSupported = provider == "sqlite"; + BackupsRoot = Path.Combine(paths.DataRoot, "backups"); + _retainCount = Math.Clamp(cfg.GetValue("Backups:RetainCount", 14), 1, 365); + } + + // Test-friendly constructor. + public SqliteDatabaseBackupRunner(string connectionString, string backupsRoot, int retainCount, ILogger logger) + { + _logger = logger; + _connectionString = connectionString; + IsSupported = true; + BackupsRoot = backupsRoot; + _retainCount = Math.Clamp(retainCount, 1, 365); + } + + public async Task RunOnceAsync(CancellationToken ct) + { + if (!IsSupported) + { + _logger.LogWarning("Automated backups only support the SQLite provider. Configure external backups for MySQL/MariaDB."); + return null; + } + + Directory.CreateDirectory(BackupsRoot); + + var target = Path.Combine(BackupsRoot, $"{BackupFilePrefix}{DateTime.UtcNow:yyyyMMdd_HHmmss}.db"); + if (File.Exists(target)) File.Delete(target); + + await using (var connection = new SqliteConnection(_connectionString)) + { + await connection.OpenAsync(ct); + await using var command = connection.CreateCommand(); + // VACUUM INTO produces a consistent, compacted snapshot without blocking writers (WAL). + command.CommandText = $"VACUUM INTO '{target.Replace("'", "''")}'"; + await command.ExecuteNonQueryAsync(ct); + } + + _logger.LogInformation("Database backup written: {File}.", target); + PruneOldBackups(); + return target; + } + + public DateTime? GetLatestBackupUtc() + { + if (!Directory.Exists(BackupsRoot)) return null; + var latest = ListBackups().FirstOrDefault(); + return latest?.LastWriteTimeUtc; + } + + private void PruneOldBackups() + { + foreach (var stale in ListBackups().Skip(_retainCount)) + { + try + { + stale.Delete(); + _logger.LogInformation("Pruned old database backup: {File}.", stale.Name); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Could not prune old database backup {File}.", stale.Name); + } + } + } + + private IOrderedEnumerable ListBackups() + => new DirectoryInfo(BackupsRoot) + .EnumerateFiles($"{BackupFilePrefix}*.db") + .OrderByDescending(f => f.LastWriteTimeUtc); + } +} diff --git a/README.md b/README.md index aaf1baf..cd27fae 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,10 @@ Common keys: - `Exports:DailyEnabled`: enable/disable daily export background job - `Exports:DailyFolder`: export destination (relative to `Data:Root` if not absolute) - `Exports:DailyHourLocal`: local hour (0–23) when the daily export runs +- `Backups:Enabled`: enable/disable the automated SQLite backup job (default `true`) +- `Backups:HourLocal`: local hour (0–23) when the daily database backup runs (default `3`) +- `Backups:RetainCount`: how many backup files to keep in `/backups` (default `14`) + - Backups use SQLite `VACUUM INTO` (consistent snapshot, safe with WAL). A catch-up backup runs at startup when none exists from the last 24 h. For MySQL/MariaDB configure external backups instead (see `deploy/MARIADB.md`). - `Auth:GoogleClientId`: if set, enables JWT bearer validation for Google ID tokens - `Auth:JwtKey`: secret used to sign local JWTs for username/password login (set via env var `Auth__JwtKey`) - `Auth:JwtIssuer`: JWT issuer (default `JobTrackerApi`) From 6150e7f19bf499876c035864b9a039b8614ab81d Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 2 Jul 2026 22:00:26 +0200 Subject: [PATCH 08/27] test(ui): stabilize slow suites and repair stale trust-loop mocks - Raise testing-library asyncUtilTimeout to 4s and jest timeout to 30s: heavy MUI views exceeded the 1s default on slower machines (profile-page, daily-control-loop double-mount). - end-to-end-trust-loop: mock the /tailored-cv-draft endpoint the redesigned Tailored CV tab now loads, and assert on the structured draft instead of the removed legacy tailoredCvText textarea. Full suite now green locally: 18/18 suites, 39/39 tests. Co-Authored-By: Claude Fable 5 --- .../src/end-to-end-trust-loop.test.tsx | 16 +++++++++++++++- job-tracker-ui/src/setupTests.ts | 7 +++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/job-tracker-ui/src/end-to-end-trust-loop.test.tsx b/job-tracker-ui/src/end-to-end-trust-loop.test.tsx index 1c449d7..d2eda85 100644 --- a/job-tracker-ui/src/end-to-end-trust-loop.test.tsx +++ b/job-tracker-ui/src/end-to-end-trust-loop.test.tsx @@ -110,6 +110,20 @@ describe('end-to-end trust loop', () => { if (url === '/jobapplications/42') return Promise.resolve({ data: jobRecord } as any); if (url === '/auth/me') return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any); if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any); + if (url === '/jobapplications/42/tailored-cv-draft') { + return Promise.resolve({ + data: { + templateId: 'ats-minimal', + headline: 'Backend Developer', + summary: ['Tailored for the Acme backend role'], + selectedSkills: [], + experience: [], + education: [], + customSections: [], + status: 'saved', + }, + } as any); + } if (url === '/attachments/42') return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any); if (url === '/correspondence/42') return Promise.resolve({ data: correspondenceMessages } as any); if (url === '/gmail/status') return Promise.resolve({ data: { connected: true, gmailAddress: 'user@example.test', lastSyncedAt: new Date().toISOString() } } as any); @@ -207,7 +221,7 @@ describe('end-to-end trust loop', () => { fireEvent.click(screen.getByRole('tab', { name: /tailored cv/i })); - expect(await screen.findByDisplayValue('Saved CV')).toBeInTheDocument(); + expect((await screen.findAllByDisplayValue(/tailored for the acme backend role/i)).length).toBeGreaterThan(0); expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument(); expect(await screen.findByDisplayValue('Saved application answer')).toBeInTheDocument(); expect(await screen.findByDisplayValue('Saved recruiter message')).toBeInTheDocument(); diff --git a/job-tracker-ui/src/setupTests.ts b/job-tracker-ui/src/setupTests.ts index 00e6cb3..b21d22f 100644 --- a/job-tracker-ui/src/setupTests.ts +++ b/job-tracker-ui/src/setupTests.ts @@ -1,4 +1,11 @@ import React from 'react'; +import { configure } from '@testing-library/react'; + +// Heavy MUI views (job table, workspace dialog, profile page) can exceed the +// 1s default async query timeout on slower machines; findBy*/waitFor assertions +// still resolve as soon as the element appears. +configure({ asyncUtilTimeout: 4000 }); +jest.setTimeout(30000); jest.mock('./api', () => ({ api: { From c41d1e8d0fe6b090f74e8a27e8749af63cf81008 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 2 Jul 2026 22:00:27 +0200 Subject: [PATCH 09/27] ci: run the entire frontend test suite instead of a file whitelist The whitelist silently skipped new suites; two regressions in non-whitelisted suites reached main unnoticed. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/ci-deploy.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci-deploy.yml b/.gitea/workflows/ci-deploy.yml index f370cc5..98468d8 100644 --- a/.gitea/workflows/ci-deploy.yml +++ b/.gitea/workflows/ci-deploy.yml @@ -43,7 +43,9 @@ jobs: - name: Test frontend working-directory: job-tracker-ui - run: npm test -- --watchAll=false --runInBand App.test.tsx confirm.test.tsx prompt.test.tsx dialog-flow.test.tsx confirm-flow.test.tsx attachments.test.tsx job-details-generated-drafts.test.tsx admin-system-page.test.tsx profile-page.test.tsx login-page.test.tsx + # Run the WHOLE suite. Never whitelist test files here again: the previous + # whitelist silently skipped new suites and let two regressions reach main. + run: npm test -- --watchAll=false --runInBand - name: Build frontend working-directory: job-tracker-ui From 8f174cb767e71287c645c7f2c12ad38011083d2d Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 2 Jul 2026 22:00:27 +0200 Subject: [PATCH 10/27] feat: dev-only OpenAPI document at /openapi/v1.json - AddOpenApi/MapOpenApi (anonymous, Development environment only). - security: mark ProfileCvController.ProcessQueuedRunAsync [NonAction] - the controller-level [Route] exposed this background-service hook as a routable any-verb endpoint, which also broke OpenAPI generation. 96 endpoint paths documented. Co-Authored-By: Claude Fable 5 --- JobTrackerApi/Controllers/ProfileCvController.cs | 4 ++++ JobTrackerApi/JobTrackerApi.csproj | 1 + JobTrackerApi/Program.cs | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index 65d50ba..a599d69 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -883,6 +883,10 @@ public sealed class ProfileCvController : ControllerBase return run; } + // Invoked by CvProcessingHostedService (this controller is also registered as a + // transient service). NonAction keeps it off the HTTP surface: without it the + // controller-level [Route] exposes it as an any-verb endpoint. + [NonAction] public async Task ProcessQueuedRunAsync(int runId, CancellationToken cancellationToken) { var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == runId, cancellationToken); diff --git a/JobTrackerApi/JobTrackerApi.csproj b/JobTrackerApi/JobTrackerApi.csproj index 5cd7b4c..feaf612 100644 --- a/JobTrackerApi/JobTrackerApi.csproj +++ b/JobTrackerApi/JobTrackerApi.csproj @@ -11,6 +11,7 @@ + diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 6a36ce0..13400eb 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -112,6 +112,7 @@ builder.Services.AddCors(options => // Add controllers builder.Services.AddControllers(); +builder.Services.AddOpenApi(); var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim(); if (string.IsNullOrWhiteSpace(dataRoot)) { @@ -441,4 +442,10 @@ app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); +// API schema for tooling/docs. Dev-only: not exposed in production deployments. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi().AllowAnonymous(); +} + app.Run(); From 83e6430a246aecf847aaf8c614c0208ff444d919 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 2 Jul 2026 22:15:37 +0200 Subject: [PATCH 11/27] feat: structured salary fields (min/max/currency/period) Adds SalaryMin/SalaryMax/SalaryCurrency/SalaryPeriod alongside the existing free-text Salary field (kept for back-compat and display). - JobApplication model + idempotent column bridging for SQLite and MySQL - Create/Update DTOs with NormalizeSalary (clamps negatives, swaps inverted min/max, uppercases currency, whitelists period) - JobApplicationDto exposes the fields; CSV export gains 4 columns - UI: add/edit dialogs get min/max/currency/period inputs; job table renders a formatted range via shared salary.ts formatter (falls back to free-text when structured values are absent) - EN/NB translations; backend + full frontend suites green Co-Authored-By: Claude Fable 5 --- .../JobApplicationsEndpointBehaviorTests.cs | 109 ++++++++++++++++++ JobTrackerApi/Controllers/ExportController.cs | 8 ++ .../Controllers/JobApplicationsController.cs | 37 ++++++ .../StartupInitializationExtensions.cs | 10 ++ Models/JobApplication.cs | 6 + job-tracker-ui/src/components/AddJobModal.tsx | 17 +++ .../src/components/EditJobDialog.tsx | 21 ++++ job-tracker-ui/src/components/JobTable.tsx | 5 +- job-tracker-ui/src/i18n/translations.ts | 14 +++ job-tracker-ui/src/salary.ts | 21 ++++ job-tracker-ui/src/types.ts | 4 + 11 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 job-tracker-ui/src/salary.ts diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index fee8f7b..16f74c0 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -56,6 +56,115 @@ public sealed class JobApplicationsEndpointBehaviorTests Assert.Contains("Profile page", badRequest.Value?.ToString()); } + [Fact] + public async Task Create_normalizes_structured_salary() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var request = new JobApplicationsController.CreateJobApplicationRequest( + JobTitle: "Backend Dev", + CompanyId: company.Id, + Status: null, + Location: null, + Salary: "60-70k", + SalaryMin: 70000m, // min > max on purpose: normalization swaps them + SalaryMax: 60000m, + SalaryCurrency: " nok ", + SalaryPeriod: "YEAR", + NextAction: null, + FollowUpAt: null, + Notes: null, + Description: null, + TranslatedDescription: null, + DescriptionLanguage: null, + Tags: null, + Deadline: null, + CoverLetterText: null, + JobUrl: null, + DateApplied: null, + FeedbackRequestedAt: null, + HasResume: null, + HasCoverLetter: null, + HasPortfolio: null, + HasOtherAttachment: null); + + var result = await controller.Create(request, CancellationToken.None); + + Assert.NotNull(result); + var saved = await db.JobApplications.FirstAsync(); + Assert.Equal(60000m, saved.SalaryMin); + Assert.Equal(70000m, saved.SalaryMax); + Assert.Equal("NOK", saved.SalaryCurrency); + Assert.Equal("year", saved.SalaryPeriod); + Assert.Equal("60-70k", saved.Salary); + } + + [Fact] + public async Task Update_drops_invalid_salary_period_and_negative_values() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Backend Dev", + CompanyId = company.Id, + OwnerUserId = "user-1", + SalaryMin = 50000m, + SalaryMax = 60000m, + SalaryCurrency = "NOK", + SalaryPeriod = "year", + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var request = new JobApplicationsController.UpdateJobApplicationRequest( + JobTitle: "Backend Dev", + CompanyId: company.Id, + Status: "Applied", + ResponseReceived: false, + ResponseDate: null, + Location: null, + Salary: null, + SalaryMin: -5m, + SalaryMax: null, + SalaryCurrency: "", + SalaryPeriod: "fortnight", + NextAction: null, + FollowUpAt: null, + HasResume: null, + HasCoverLetter: null, + HasPortfolio: null, + HasOtherAttachment: null, + Notes: null, + Description: null, + TranslatedDescription: null, + DescriptionLanguage: null, + Tags: null, + Deadline: null, + CoverLetterText: null, + JobUrl: null, + DateApplied: null, + FeedbackRequestedAt: null, + StatusChangedAt: null); + + var result = await controller.Update(job.Id, request, CancellationToken.None); + + Assert.IsType(result); + var saved = await db.JobApplications.FirstAsync(); + Assert.Null(saved.SalaryMin); + Assert.Null(saved.SalaryMax); + Assert.Null(saved.SalaryCurrency); + Assert.Null(saved.SalaryPeriod); + } + private static JobApplicationsController CreateController(JobTrackerContext db, string userId) { var summarizer = new Mock(); diff --git a/JobTrackerApi/Controllers/ExportController.cs b/JobTrackerApi/Controllers/ExportController.cs index 8af165c..2c1ba03 100644 --- a/JobTrackerApi/Controllers/ExportController.cs +++ b/JobTrackerApi/Controllers/ExportController.cs @@ -58,6 +58,10 @@ namespace JobTrackerApi.Controllers "DateApplied", "Location", "Salary", + "SalaryMin", + "SalaryMax", + "SalaryCurrency", + "SalaryPeriod", "NextAction", "FollowUpAt", "JobUrl", @@ -76,6 +80,10 @@ namespace JobTrackerApi.Controllers Esc(j.DateApplied.ToString("o")), Esc(j.Location), Esc(j.Salary), + Esc(j.SalaryMin?.ToString(System.Globalization.CultureInfo.InvariantCulture)), + Esc(j.SalaryMax?.ToString(System.Globalization.CultureInfo.InvariantCulture)), + Esc(j.SalaryCurrency), + Esc(j.SalaryPeriod), Esc(j.NextAction), Esc(j.FollowUpAt?.ToString("o")), Esc(j.JobUrl), diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 58f6884..dc9b788 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -749,6 +749,10 @@ Canonical profile: Deadline: job.Deadline, Location: job.Location, Salary: job.Salary, + SalaryMin: job.SalaryMin, + SalaryMax: job.SalaryMax, + SalaryCurrency: job.SalaryCurrency, + SalaryPeriod: job.SalaryPeriod, NextAction: job.NextAction, FollowUpAt: job.FollowUpAt, FeedbackRequestedAt: job.FeedbackRequestedAt, @@ -1081,6 +1085,10 @@ Canonical profile: DateTime? Deadline, string? Location, string? Salary, + decimal? SalaryMin, + decimal? SalaryMax, + string? SalaryCurrency, + string? SalaryPeriod, string? NextAction, DateTime? FollowUpAt, DateTime? FeedbackRequestedAt, @@ -1349,6 +1357,10 @@ Canonical profile: string? Status, string? Location, string? Salary, + decimal? SalaryMin, + decimal? SalaryMax, + string? SalaryCurrency, + string? SalaryPeriod, string? NextAction, DateTime? FollowUpAt, string? Notes, @@ -1367,6 +1379,22 @@ Canonical profile: bool? HasOtherAttachment ); + private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary( + decimal? min, decimal? max, string? currency, string? period) + { + if (min is < 0) min = null; + if (max is < 0) max = null; + if (min.HasValue && max.HasValue && min > max) (min, max) = (max, min); + + var cur = (currency ?? "").Trim().ToUpperInvariant(); + if (cur.Length > 8) cur = cur[..8]; + + var per = (period ?? "").Trim().ToLowerInvariant(); + if (per is not ("year" or "month" or "hour")) per = ""; + + return (min, max, cur.Length == 0 ? null : cur, per.Length == 0 ? null : per); + } + [HttpPost] public async Task> Create([FromBody] CreateJobApplicationRequest request, CancellationToken cancellationToken) { @@ -1409,6 +1437,9 @@ Canonical profile: ResponseDate = null, }; + (job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) = + NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod); + // Generate and persist a short summary at creation time to avoid repeated model calls. try { @@ -1447,6 +1478,10 @@ Canonical profile: DateTime? ResponseDate, string? Location, string? Salary, + decimal? SalaryMin, + decimal? SalaryMax, + string? SalaryCurrency, + string? SalaryPeriod, string? NextAction, DateTime? FollowUpAt, bool? HasResume, @@ -1487,6 +1522,8 @@ Canonical profile: job.ResponseDate = request.ResponseDate; job.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(); job.Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim(); + (job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) = + NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod); job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(); job.FollowUpAt = request.FollowUpAt; job.FeedbackRequestedAt = request.FeedbackRequestedAt; diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index fb76114..d71fff0 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -484,6 +484,12 @@ public static class StartupInitializationExtensions EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;"); EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;"); + // Structured salary fields (EF maps decimal to TEXT on SQLite). + EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod 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;"); @@ -607,6 +613,10 @@ public static class StartupInitializationExtensions 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", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) 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;"); diff --git a/Models/JobApplication.cs b/Models/JobApplication.cs index dc8d3b6..729c6ef 100644 --- a/Models/JobApplication.cs +++ b/Models/JobApplication.cs @@ -13,6 +13,12 @@ public class JobApplication public DateTime DateApplied { get; set; } = DateTime.UtcNow; public string? Location { get; set; } public string? Salary { get; set; } + + // Structured salary; the free-text Salary field is kept for display/back-compat. + public decimal? SalaryMin { get; set; } + public decimal? SalaryMax { get; set; } + public string? SalaryCurrency { get; set; } // e.g. "NOK", "GBP", "EUR" + public string? SalaryPeriod { get; set; } // "year" | "month" | "hour" public string? NextAction { get; set; } public DateTime? FollowUpAt { get; set; } public DateTime? FeedbackRequestedAt { get; set; } diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index 9d6accf..c0668d9 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -118,6 +118,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied"); const [location, setLocation] = useState(""); const [salary, setSalary] = useState(""); + const [salaryMin, setSalaryMin] = useState(""); + const [salaryMax, setSalaryMax] = useState(""); + const [salaryCurrency, setSalaryCurrency] = useState(""); + const [salaryPeriod, setSalaryPeriod] = useState(""); const [jobUrl, setJobUrl] = useState(""); const [deadline, setDeadline] = useState(""); @@ -291,6 +295,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { status, location, salary, + salaryMin: salaryMin.trim() ? Number(salaryMin) : null, + salaryMax: salaryMax.trim() ? Number(salaryMax) : null, + salaryCurrency: salaryCurrency.trim() || null, + salaryPeriod: salaryPeriod || null, nextAction: null, followUpAt: null, jobUrl, @@ -482,6 +490,15 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { setLocation(e.target.value)} sx={FIELD_SX} /> setSalary(e.target.value)} sx={FIELD_SX} /> + setSalaryMin(e.target.value)} sx={FIELD_SX} /> + setSalaryMax(e.target.value)} sx={FIELD_SX} /> + setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} /> + setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}> + + + + + new Date().toISOString().slice(0, 10)); const [location, setLocation] = useState(""); const [salary, setSalary] = useState(""); + const [salaryMin, setSalaryMin] = useState(""); + const [salaryMax, setSalaryMax] = useState(""); + const [salaryCurrency, setSalaryCurrency] = useState(""); + const [salaryPeriod, setSalaryPeriod] = useState(""); const [nextAction, setNextAction] = useState(""); const [followUpAt, setFollowUpAt] = useState(""); const [jobUrl, setJobUrl] = useState(""); @@ -110,6 +114,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) setDateApplied(toDateInputValue(j.dateApplied)); setLocation(j.location ?? ""); setSalary(j.salary ?? ""); + setSalaryMin(j.salaryMin != null ? String(j.salaryMin) : ""); + setSalaryMax(j.salaryMax != null ? String(j.salaryMax) : ""); + setSalaryCurrency(j.salaryCurrency ?? ""); + setSalaryPeriod(j.salaryPeriod ?? ""); setNextAction((j as any).nextAction ?? ""); setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : ""); setJobUrl(j.jobUrl ?? ""); @@ -144,6 +152,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) responseDate: responseReceived && responseDate ? responseDate : null, location: location.trim() || null, salary: salary.trim() || null, + salaryMin: salaryMin.trim() ? Number(salaryMin) : null, + salaryMax: salaryMax.trim() ? Number(salaryMax) : null, + salaryCurrency: salaryCurrency.trim() || null, + salaryPeriod: salaryPeriod || null, nextAction: nextAction.trim() || null, followUpAt: followUpAt || null, hasResume, @@ -210,6 +222,15 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) setLocation(e.target.value)} sx={FIELD_SX} /> setSalary(e.target.value)} sx={FIELD_SX} /> + setSalaryMin(e.target.value)} sx={FIELD_SX} /> + setSalaryMax(e.target.value)} sx={FIELD_SX} /> + setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} /> + setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}> + + + + + setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} /> setDescriptionLanguage(e.target.value)} sx={FIELD_SX} /> diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 4970332..d0586ce 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -44,6 +44,7 @@ import { api } from "../api"; import ViewStateNotice from "./ViewStateNotice"; import { useCompanies } from "../hooks/useCompanies"; import { useDebouncedValue } from "../hooks/useDebouncedValue"; +import { formatSalary } from "../salary"; import JobDetailsDialog from "./JobDetailsDialog"; import EditJobDialog from "./EditJobDialog"; import { useToast } from "../toast"; @@ -584,7 +585,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {t("addJobModalSalary")} - {job.salary ?? "-"} + {formatSalary(job) ?? "-"} @@ -727,7 +728,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {t("jobTableLocation")}{job.location ?? "-"} - {t("addJobModalSalary")}{job.salary ?? "-"} + {t("addJobModalSalary")}{formatSalary(job) ?? "-"} {t("settingsColumnJobUrl")}{job.jobUrl ? {t("jobTableOpenListing")} : "-"} {t("jobTableSkills")}{detailTags.length ? detailTags.map((tag) => ) : {t("jobTableNoTags")}} {t("jobTableOverview")}{overview || t("jobTableNoSummaryYet")} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 1769666..fdf618f 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -77,6 +77,13 @@ export const translations = { addJobModalStatus: "Status", addJobModalJobTitle: "Job title", addJobModalSalary: "Salary", + salaryMinLabel: "Salary min", + salaryMaxLabel: "Salary max", + salaryCurrencyLabel: "Currency", + salaryPeriodLabel: "Per", + salaryPeriodYear: "Year", + salaryPeriodMonth: "Month", + salaryPeriodHour: "Hour", addJobModalDeadline: "Deadline", addJobModalDescriptionOriginal: "Description (original)", addJobModalTranslatedDescription: "Translated description ({language})", @@ -987,6 +994,13 @@ export const translations = { addJobModalStatus: "Status", addJobModalJobTitle: "Stillingstittel", addJobModalSalary: "Lønn", + salaryMinLabel: "Lønn fra", + salaryMaxLabel: "Lønn til", + salaryCurrencyLabel: "Valuta", + salaryPeriodLabel: "Per", + salaryPeriodYear: "År", + salaryPeriodMonth: "Måned", + salaryPeriodHour: "Time", addJobModalDeadline: "Frist", addJobModalDescriptionOriginal: "Beskrivelse (original)", addJobModalTranslatedDescription: "Oversatt beskrivelse ({language})", diff --git a/job-tracker-ui/src/salary.ts b/job-tracker-ui/src/salary.ts new file mode 100644 index 0000000..e5586ab --- /dev/null +++ b/job-tracker-ui/src/salary.ts @@ -0,0 +1,21 @@ +import { JobApplication } from "./types"; + +type SalaryFields = Pick; + +const PERIOD_SUFFIX: Record = { year: "yr", month: "mo", hour: "hr" }; + +/** Structured salary when present ("60 000–70 000 NOK/yr"), otherwise the free-text field. */ +export function formatSalary(job: SalaryFields): string | null { + const { salaryMin, salaryMax, salaryCurrency, salaryPeriod } = job; + if (salaryMin == null && salaryMax == null) { + return job.salary?.trim() || null; + } + + const fmt = (value: number) => value.toLocaleString(); + const range = salaryMin != null && salaryMax != null && salaryMin !== salaryMax + ? `${fmt(salaryMin)}–${fmt(salaryMax)}` + : fmt((salaryMin ?? salaryMax) as number); + const currency = salaryCurrency ? ` ${salaryCurrency}` : ""; + const period = salaryPeriod ? `/${PERIOD_SUFFIX[salaryPeriod] ?? salaryPeriod}` : ""; + return `${range}${currency}${period}`; +} diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index 0623420..fd70fc3 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -89,6 +89,10 @@ export interface JobApplication { dateApplied: string; location?: string; salary?: string; + salaryMin?: number | null; + salaryMax?: number | null; + salaryCurrency?: string | null; + salaryPeriod?: string | null; nextAction?: string; followUpAt?: string; feedbackRequestedAt?: string; From 3fad43a9e2dc7152964d449ec35856d644a7bf5e Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 03:16:32 +0200 Subject: [PATCH 12/27] feat: deterministic CV-to-job match score endpoint New JobCvMatchService: a pure, AI-free keyword-coverage scorer that returns a stable, reproducible 0-100 match score plus matched/missing keyword lists and per-CV-section coverage. Unlike candidate-fit (AI narrative), it makes no model calls, so results are instant and identical for identical inputs - the Jobscan-style differentiator. - GET /api/jobapplications/{id}/match-score - keywords = curated SkillTagger tags (high weight) + salient posting terms (title terms boosted); word-boundary matching avoids false hits - section coverage shows where CV evidence is concentrated - fix(SkillTagger): punctuation-tolerant C#/.NET patterns; the old \b boundaries silently missed 'C#,' and '.NET,' everywhere they are used - 7 unit tests on the pure scorer; full backend suite green (104) Co-Authored-By: Claude Fable 5 --- JobTrackerApi.Tests/JobCvMatchServiceTests.cs | 105 +++++++++ .../Controllers/JobApplicationsController.cs | 87 +++++++- JobTrackerApi/Program.cs | 1 + JobTrackerApi/Services/JobCvMatchService.cs | 208 ++++++++++++++++++ .../Services/JobImport/SkillTagger.cs | 6 +- 5 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 JobTrackerApi.Tests/JobCvMatchServiceTests.cs create mode 100644 JobTrackerApi/Services/JobCvMatchService.cs diff --git a/JobTrackerApi.Tests/JobCvMatchServiceTests.cs b/JobTrackerApi.Tests/JobCvMatchServiceTests.cs new file mode 100644 index 0000000..a2c9673 --- /dev/null +++ b/JobTrackerApi.Tests/JobCvMatchServiceTests.cs @@ -0,0 +1,105 @@ +using JobTrackerApi.Services; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class JobCvMatchServiceTests +{ + private readonly JobCvMatchService _service = new(); + + private static Dictionary Sections(params (string Name, string Text)[] items) + => items.ToDictionary(i => i.Name, i => i.Text, StringComparer.OrdinalIgnoreCase); + + [Fact] + public void Strong_overlap_scores_high_and_lists_matched_keywords() + { + var result = _service.Evaluate( + jobTitle: "Senior C# Backend Developer", + jobText: "We need a backend engineer with strong C#, .NET, SQL and Docker experience building REST APIs.", + cvSections: Sections( + ("Skills", "C# .NET SQL Docker Kubernetes"), + ("Experience", "Built REST APIs in C# and .NET with SQL Server and Docker."))); + + Assert.True(result.Score >= 75, $"expected strong score, got {result.Score}"); + Assert.Equal("Strong", result.Band); + Assert.Contains("C#", result.MatchedKeywords); + Assert.Contains(".NET", result.MatchedKeywords); + Assert.True(result.HasEnoughSignal); + } + + [Fact] + public void No_overlap_scores_low_and_surfaces_missing_keywords() + { + var result = _service.Evaluate( + jobTitle: "Kubernetes Platform Engineer", + jobText: "Deep Kubernetes, AWS, and Docker platform experience required. Terraform and CI/CD pipelines.", + cvSections: Sections( + ("Skills", "Graphic design, Adobe Photoshop, Illustrator, copywriting"), + ("Experience", "Ran marketing campaigns and brand design work."))); + + Assert.True(result.Score < 50, $"expected low score, got {result.Score}"); + Assert.Equal("Low", result.Band); + Assert.Contains("Kubernetes", result.MissingKeywords); + Assert.Contains("AWS", result.MissingKeywords); + } + + [Fact] + public void Is_deterministic_for_identical_inputs() + { + var a = _service.Evaluate("Data Engineer", "Python, SQL, Spark, ETL pipelines and AWS.", Sections(("Skills", "Python SQL AWS"))); + var b = _service.Evaluate("Data Engineer", "Python, SQL, Spark, ETL pipelines and AWS.", Sections(("Skills", "Python SQL AWS"))); + + Assert.Equal(a.Score, b.Score); + Assert.Equal(a.MatchedKeywords, b.MatchedKeywords); + Assert.Equal(a.MissingKeywords, b.MissingKeywords); + } + + [Fact] + public void Word_boundary_prevents_false_substring_matches() + { + // "go" (the language) must not match inside "goals"/"ago". + var result = _service.Evaluate( + jobTitle: "Go Developer", + jobText: "Go programming language, goroutines, concurrency.", + cvSections: Sections(("Experience", "Achieved company goals two years ago in a great environment."))); + + Assert.DoesNotContain("go", result.MatchedKeywords, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void Section_coverage_reports_where_matches_are_concentrated() + { + var result = _service.Evaluate( + jobTitle: "React Frontend Engineer", + jobText: "Build UIs with React, TypeScript and JavaScript. Strong testing culture.", + cvSections: Sections( + ("Skills", "React TypeScript JavaScript"), + ("Experience", "Wrote documentation and managed budgets."))); + + var skills = Assert.Single(result.SectionCoverage, s => s.Section == "Skills"); + var experience = Assert.Single(result.SectionCoverage, s => s.Section == "Experience"); + Assert.True(skills.Matched > experience.Matched); + } + + [Fact] + public void Empty_cv_reports_no_signal() + { + var result = _service.Evaluate("Anything", "Some role text with several words here.", Sections()); + Assert.False(result.HasEnoughSignal); + Assert.Equal("Unknown", result.Band); + Assert.Equal(0, result.MatchedCount); + } + + [Fact] + public void Title_keywords_are_weighted_and_missing_ones_rank_first() + { + // The title term "kubernetes" is absent from the CV; it should lead the missing list + // because title terms carry the title bonus weight. + var result = _service.Evaluate( + jobTitle: "Kubernetes Specialist", + jobText: "Kubernetes orchestration. Some familiarity with logging and monitoring dashboards.", + cvSections: Sections(("Skills", "logging monitoring dashboards"))); + + Assert.Equal("Kubernetes", result.MissingKeywords.First()); + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index dc9b788..03deca7 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -23,8 +23,9 @@ namespace JobTrackerApi.Controllers private readonly ILogger _logger; private readonly ICvTemplateRenderer _cvTemplateRenderer; private readonly ICvPdfExporter _cvPdfExporter; + private readonly IJobCvMatchService _matchService; - public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null) + public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, IJobCvMatchService? matchService = null) { _db = db; _summarizer = summarizer; @@ -33,6 +34,7 @@ namespace JobTrackerApi.Controllers _logger = logger; _cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer(); _cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter(); + _matchService = matchService ?? new JobCvMatchService(); } private sealed class ThrowingCvPdfExporter : ICvPdfExporter @@ -2107,6 +2109,89 @@ Canonical profile: }; } + public sealed record MatchScoreDto( + int Score, + string Band, + int MatchedCount, + int TotalKeywords, + List MatchedKeywords, + List MissingKeywords, + List SectionCoverage, + bool HasEnoughSignal); + + public sealed record MatchSectionCoverageDto(string Section, int Matched, int Total); + + // Builds CV text grouped by section so match coverage can show *where* the evidence sits. + private static Dictionary BuildCvSections(ApplicationUser? user) + { + var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson); + var sections = new Dictionary(StringComparer.OrdinalIgnoreCase); + + void Add(string name, IEnumerable values) + { + var text = string.Join("\n", values.Where(v => !string.IsNullOrWhiteSpace(v))); + if (!string.IsNullOrWhiteSpace(text)) sections[name] = text; + } + + Add("Summary", new[] { structured.Contact.Headline }.Concat(structured.Summary)); + Add("Skills", structured.Skills); + Add("Experience", structured.Jobs.SelectMany(job => + new[] { job.Title, job.Company }.Concat(job.Bullets).Concat(job.Skills))); + Add("Education", structured.Education.SelectMany(ed => + new[] { ed.Qualification, ed.Institution }.Concat(ed.Details))); + + // Always include raw profile text (covers users who only pasted plain CV text, and + // catches keywords the structured sections missed). + if (!string.IsNullOrWhiteSpace(user?.ProfileCvText)) + { + sections["Profile"] = user!.ProfileCvText!; + } + + return sections; + } + + /// + /// Fast, deterministic CV↔job keyword coverage score. Unlike candidate-fit (AI narrative), + /// this makes no model calls, so it returns instantly and reproducibly. + /// + [HttpGet("{id:int}/match-score")] + public async Task> GetMatchScore([FromRoute] int id, CancellationToken cancellationToken) + { + var job = await _db.JobApplications + .Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var userId = CurrentUserId; + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + + var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + var cvSections = BuildCvSections(user); + if (cvSections.Count == 0) + { + return BadRequest("Add your profile CV on the Profile page before running the match score."); + } + + var jobText = string.Join("\n\n", new[] { job.Description, job.TranslatedDescription, job.Notes } + .Where(x => !string.IsNullOrWhiteSpace(x))); + if (string.IsNullOrWhiteSpace(jobText)) + { + return BadRequest("This job does not have enough description or notes to compare against your CV."); + } + + var result = _matchService.Evaluate(job.JobTitle, jobText, cvSections); + + return Ok(new MatchScoreDto( + Score: result.Score, + Band: result.Band, + MatchedCount: result.MatchedCount, + TotalKeywords: result.TotalKeywords, + MatchedKeywords: result.MatchedKeywords.ToList(), + MissingKeywords: result.MissingKeywords.ToList(), + SectionCoverage: result.SectionCoverage.Select(s => new MatchSectionCoverageDto(s.Section, s.Matched, s.Total)).ToList(), + HasEnoughSignal: result.HasEnoughSignal)); + } + [HttpGet("{id:int}/candidate-fit")] public async Task> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) { diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 13400eb..7969d21 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -157,6 +157,7 @@ builder.Services.AddHttpClient("ai-service", client => builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/JobCvMatchService.cs b/JobTrackerApi/Services/JobCvMatchService.cs new file mode 100644 index 0000000..7756d69 --- /dev/null +++ b/JobTrackerApi/Services/JobCvMatchService.cs @@ -0,0 +1,208 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using JobTrackerApi.Services.JobImport; + +namespace JobTrackerApi.Services +{ + /// One keyword drawn from the job posting and whether the CV covers it. + public sealed record MatchKeyword(string Keyword, int Weight, bool InTitle, bool Matched); + + /// How many of the matched keywords appear in a given CV section. + public sealed record MatchSectionCoverage(string Section, int Matched, int Total); + + public sealed record JobCvMatchResult( + int Score, + string Band, + int MatchedCount, + int TotalKeywords, + IReadOnlyList MatchedKeywords, + IReadOnlyList MissingKeywords, + IReadOnlyList SectionCoverage, + bool HasEnoughSignal); + + public interface IJobCvMatchService + { + JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary cvSections); + } + + /// + /// Deterministic CV↔job keyword coverage score. No AI: the same inputs always produce the + /// same number so users get a stable, reproducible signal (the Jobscan-style differentiator). + /// The AI narrative lives separately in the candidate-fit endpoint. + /// + public sealed class JobCvMatchService : IJobCvMatchService + { + // Weights: curated skill tags are high-signal; salient posting terms are the long tail. + private const int CuratedTagWeight = 3; + private const int TermWeight = 1; + private const int TitleBonus = 2; + private const int MaxKeywords = 28; + + private static readonly Regex TokenPattern = new(@"[a-z0-9][a-z0-9+.#-]*", RegexOptions.Compiled); + + private static readonly HashSet StopWords = new(StringComparer.OrdinalIgnoreCase) + { + "the", "and", "for", "with", "you", "your", "our", "are", "will", "have", "has", "that", + "this", "from", "not", "but", "all", "can", "who", "how", "why", "what", "when", "who", + "job", "role", "work", "working", "team", "teams", "company", "years", "year", "experience", + "experienced", "skills", "skill", "ability", "able", "strong", "good", "great", "excellent", + "including", "include", "includes", "well", "using", "use", "used", "within", "across", + "into", "onto", "their", "them", "they", "were", "was", "would", "should", "could", "must", + "new", "also", "per", "via", "etc", "such", "any", "one", "two", "three", "day", "days", + "week", "weeks", "month", "months", "time", "full", "part", "based", "join", "looking", + "seeking", "candidate", "candidates", "applicant", "position", "positions", "opportunity", + "responsibilities", "requirements", "required", "preferred", "plus", "nice", "want", "need", + "needs", "help", "make", "made", "get", "got", "more", "most", "many", "much", "each", + "other", "others", "some", "than", "then", "there", "here", "about", "over", "under", "out", + "off", "its", "his", "her", "she", "him", "may", "might", "high", "low", "level", "levels", + "environment", "environments", "world", "people", "person", "customer", "customers", "client", + "clients", "product", "products", "service", "services", "business", "solution", "solutions", + "project", "projects", "process", "processes", "development", "develop", "developer", + // Seniority / role-title words: noise for CV keyword matching (the hard skills are what count). + "senior", "junior", "lead", "principal", "mid", "staff", "engineer", "engineers", + "engineering", "manager", "specialist", "analyst", "consultant", "administrator", + "coordinator", "associate", "intern", "officer", "director", "professional", + }; + + public JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary cvSections) + { + jobTitle ??= string.Empty; + jobText ??= string.Empty; + cvSections ??= new Dictionary(); + + var titleTokens = Tokenize(jobTitle).ToHashSet(StringComparer.OrdinalIgnoreCase); + var keywords = BuildKeywords(jobTitle, jobText, titleTokens); + + // Combine all CV sections into one searchable corpus, plus keep per-section text for coverage. + var sectionCorpora = cvSections + .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value)) + .ToDictionary(kvp => kvp.Key, kvp => Normalize(kvp.Value), StringComparer.OrdinalIgnoreCase); + var fullCorpus = string.Join(" \n ", sectionCorpora.Values); + + var evaluated = keywords + .Select(k => k with { Matched = CorpusContains(fullCorpus, k.Keyword) }) + .ToList(); + + var totalWeight = evaluated.Sum(k => k.Weight); + var matchedWeight = evaluated.Where(k => k.Matched).Sum(k => k.Weight); + var hasEnoughSignal = evaluated.Count >= 3 && sectionCorpora.Count > 0; + + var score = totalWeight == 0 ? 0 : (int)Math.Round(100.0 * matchedWeight / totalWeight, MidpointRounding.AwayFromZero); + score = Math.Clamp(score, 0, 100); + + var band = !hasEnoughSignal ? "Unknown" : score >= 75 ? "Strong" : score >= 50 ? "Partial" : "Low"; + + var matchedKeywords = evaluated.Where(k => k.Matched) + .OrderByDescending(k => k.Weight).ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase) + .Select(k => k.Keyword).ToList(); + var missingKeywords = evaluated.Where(k => !k.Matched) + .OrderByDescending(k => k.Weight).ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase) + .Select(k => k.Keyword).ToList(); + + var sectionCoverage = sectionCorpora + .Select(section => new MatchSectionCoverage( + section.Key, + evaluated.Count(k => CorpusContains(section.Value, k.Keyword)), + evaluated.Count)) + .Where(sc => sc.Total > 0) + .OrderByDescending(sc => sc.Matched) + .ToList(); + + return new JobCvMatchResult( + Score: score, + Band: band, + MatchedCount: matchedKeywords.Count, + TotalKeywords: evaluated.Count, + MatchedKeywords: matchedKeywords, + MissingKeywords: missingKeywords, + SectionCoverage: sectionCoverage, + HasEnoughSignal: hasEnoughSignal); + } + + private static List BuildKeywords(string jobTitle, string jobText, HashSet titleTokens) + { + var combined = $"{jobTitle}\n{jobText}"; + var byKey = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // 1) Curated skill tags: high-signal, canonical spelling. + foreach (var tag in SkillTagger.Detect(combined)) + { + var inTitle = TitleContains(jobTitle, tag); + byKey[tag] = new MatchKeyword(tag, CuratedTagWeight + (inTitle ? TitleBonus : 0), inTitle, false); + } + + // 2) Salient posting terms: frequency-ranked content words from the description. + var frequencies = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var token in Tokenize(jobText)) + { + if (token.Length < 3 || StopWords.Contains(token) || IsNumeric(token)) continue; + frequencies[token] = frequencies.TryGetValue(token, out var c) ? c + 1 : 1; + } + + var rankedTerms = frequencies + .Where(kvp => kvp.Value >= 1) + .OrderByDescending(kvp => titleTokens.Contains(kvp.Key) ? 1 : 0) + .ThenByDescending(kvp => kvp.Value) + .ThenBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase) + .Select(kvp => kvp.Key); + + foreach (var term in rankedTerms) + { + if (byKey.Count >= MaxKeywords) break; + if (byKey.ContainsKey(term)) continue; + var inTitle = titleTokens.Contains(term); + byKey[term] = new MatchKeyword(term, TermWeight + (inTitle ? TitleBonus : 0), inTitle, false); + } + + return byKey.Values + .OrderByDescending(k => k.Weight) + .ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase) + .Take(MaxKeywords) + .ToList(); + } + + private static bool TitleContains(string title, string phrase) + => Normalize(title).Contains(Normalize(phrase), StringComparison.Ordinal); + + private static bool CorpusContains(string normalizedCorpus, string keyword) + { + var needle = Normalize(keyword); + if (needle.Length == 0) return false; + // Word-boundary-ish match to avoid "go" matching "goal". + var idx = normalizedCorpus.IndexOf(needle, StringComparison.Ordinal); + while (idx >= 0) + { + var beforeOk = idx == 0 || !char.IsLetterOrDigit(normalizedCorpus[idx - 1]); + var afterPos = idx + needle.Length; + var afterOk = afterPos >= normalizedCorpus.Length || !char.IsLetterOrDigit(normalizedCorpus[afterPos]); + if (beforeOk && afterOk) return true; + idx = normalizedCorpus.IndexOf(needle, idx + 1, StringComparison.Ordinal); + } + return false; + } + + private static IEnumerable Tokenize(string text) + { + if (string.IsNullOrWhiteSpace(text)) yield break; + foreach (Match m in TokenPattern.Matches(text.ToLowerInvariant())) + { + yield return m.Value.Trim('-', '.', '+', '#'); + } + } + + private static bool IsNumeric(string token) + => token.All(c => char.IsDigit(c) || c is '.' or '-' or '+'); + + private static string Normalize(string text) + { + if (string.IsNullOrWhiteSpace(text)) return string.Empty; + var sb = new StringBuilder(text.Length); + foreach (var ch in text.ToLowerInvariant()) + { + sb.Append(char.IsWhiteSpace(ch) ? ' ' : ch); + } + return sb.ToString(); + } + } +} diff --git a/JobTrackerApi/Services/JobImport/SkillTagger.cs b/JobTrackerApi/Services/JobImport/SkillTagger.cs index c1c867a..857ea08 100644 --- a/JobTrackerApi/Services/JobImport/SkillTagger.cs +++ b/JobTrackerApi/Services/JobImport/SkillTagger.cs @@ -9,8 +9,10 @@ public static class SkillTagger { private static readonly (string Tag, Regex Pattern, int Weight)[] Patterns = { - ("C#", new Regex(@"\bC#\b|\bcsharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6), - (".NET", new Regex(@"\b\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6), + // Symbol skills need punctuation-tolerant boundaries: \b fails next to '#'/'.' + // (both non-word chars), which previously left "C#," and ".NET," undetected. + ("C#", new Regex(@"(? Date: Fri, 3 Jul 2026 03:24:53 +0200 Subject: [PATCH 13/27] feat(ui): instant match-score panel on the Candidate Fit tab Adds a MatchScoreCard at the top of the Candidate Fit tab that loads the deterministic /match-score endpoint independently of the slow AI narrative, so users see a reproducible score, matched/missing keyword chips, and per-section coverage immediately. - MatchScore types + cached, attachment-independent load effect - graceful 'not enough signal' state - EN/NB translations - frontend panel test (matched/missing/section + degraded state) - backend integration tests for GetMatchScore (happy path + missing CV) - README endpoint reference Co-Authored-By: Claude Fable 5 --- .../JobApplicationsEndpointBehaviorTests.cs | 54 +++++++++ README.md | 2 + .../src/components/JobDetailsDialog.tsx | 93 +++++++++++++- job-tracker-ui/src/i18n/translations.ts | 28 +++++ job-tracker-ui/src/match-score-panel.test.tsx | 113 ++++++++++++++++++ job-tracker-ui/src/types.ts | 17 +++ 6 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 job-tracker-ui/src/match-score-panel.test.tsx diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index 16f74c0..0592b72 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -56,6 +56,60 @@ public sealed class JobApplicationsEndpointBehaviorTests Assert.Contains("Profile page", badRequest.Value?.ToString()); } + [Fact] + public async Task Match_score_scores_job_against_profile_cv() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + db.Users.Add(new ApplicationUser + { + Id = "user-1", + UserName = "u", + Email = "u@example.com", + ProfileCvText = "Backend engineer skilled in C#, .NET, SQL and Docker. Built REST APIs.", + }); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Senior C# Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1", + Description = "We need strong C#, .NET, SQL, Docker and REST API experience.", + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var result = await controller.GetMatchScore(job.Id, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.True(dto.HasEnoughSignal); + Assert.True(dto.Score >= 75, $"expected strong score, got {dto.Score}"); + Assert.Contains("C#", dto.MatchedKeywords); + } + + [Fact] + public async Task Match_score_requires_profile_cv() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "u", Email = "u@example.com" }); + await db.SaveChangesAsync(); + + var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Description = "C# .NET" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var result = await controller.GetMatchScore(job.Id, CancellationToken.None); + + Assert.IsType(result.Result); + } + [Fact] public async Task Create_normalizes_structured_salary() { diff --git a/README.md b/README.md index cd27fae..127e26a 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,8 @@ Authentication: - Returns a unified timeline combining job events, correspondence, and attachments. - `GET /api/jobapplications/stats` - Returns totals, counts by status, applied-last-30-days, and average days since applied. +- `GET /api/jobapplications/{id}/match-score` + - Deterministic CV↔job keyword-coverage score (0–100) with matched/missing keywords and per-CV-section coverage. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is `GET /api/jobapplications/{id}/candidate-fit`.) - `DELETE /api/jobapplications/{id}` - Soft-deletes an application (`IsDeleted=true`); records a `Deleted` event. - `POST /api/jobapplications/{id}/restore` diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 1b5d144..b7b791f 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -10,6 +10,7 @@ import { DialogTitle, FormControl, InputLabel, + LinearProgress, MenuItem, Select, Tab, @@ -19,7 +20,7 @@ import { } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; -import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse, TailoredCvDraft } from "../types"; +import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, TailoredCvDraft } from "../types"; import { useToast } from "../toast"; import { useDialogActions } from "../dialogs"; import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft"; @@ -130,6 +131,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const { confirmAction } = useDialogActions(); const followUpCache = useWorkspaceTabCache(); const candidateFitCache = useWorkspaceTabCache(); + const matchScoreCache = useWorkspaceTabCache(); const focusPlanCache = useWorkspaceTabCache(); const interviewPrepCache = useWorkspaceTabCache(); const readinessCache = useWorkspaceTabCache(); @@ -168,6 +170,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const [sendingDraft, setSendingDraft] = useState(false); const [refreshingAi, setRefreshingAi] = useState(false); const [candidateFit, setCandidateFit] = useState(null); + const [matchScore, setMatchScore] = useState(null); + const [loadingMatchScore, setLoadingMatchScore] = useState(false); const [focusPlan, setFocusPlan] = useState(null); const [loadingCandidateFit, setLoadingCandidateFit] = useState(false); const [loadingFocusPlan, setLoadingFocusPlan] = useState(false); @@ -200,6 +204,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, if (!open || !jobId) return; setFollowUpDraft(null); setCandidateFit(null); + setMatchScore(null); setFocusPlan(null); setInterviewPrep(null); setReadiness(null); @@ -280,6 +285,24 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, }).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false)); }, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]); + // Match score is deterministic and cheap: load it on the Candidate Fit tab + // independently of the slow AI narrative so users see the number instantly. + useEffect(() => { + if (!open || !jobId || tab !== 5 || matchScore) return; + const cacheKey = `${jobId}:match-score`; + const cached = matchScoreCache.getCached(cacheKey); + if (cached) { + setMatchScore(cached); + return; + } + + setLoadingMatchScore(true); + api.get(`/jobapplications/${jobId}/match-score`).then((r) => { + matchScoreCache.setCached(cacheKey, r.data); + setMatchScore(r.data); + }).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false)); + }, [open, jobId, tab, matchScore, matchScoreCache]); + useEffect(() => { if (!open || !jobId || tab !== 6 || focusPlan) return; const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`; @@ -1058,6 +1081,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {tab === 5 && ( + {loadingCandidateFit ? : candidateFit ? ( @@ -1136,6 +1160,73 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, ); } +function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) { + const { t } = useI18n(); + + if (loading && !score) { + return ( + + + {t("matchScoreLoading")} + + ); + } + + if (!score) return null; + + const color: "success" | "warning" | "error" | "inherit" = + !score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error"; + const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band; + + return ( + + + + {score.hasEnoughSignal ? `${score.score}%` : "—"} + {t("matchScoreTitle")} + + + + + + + {score.hasEnoughSignal ? ( + + ) : ( + {t("matchScoreNoSignal")} + )} + {t("matchScoreDeterministicHint")} + + + {t("matchScoreMatched")} + + {score.matchedKeywords.length ? score.matchedKeywords.map((k) => ) : {t("matchScoreNoneYet")}} + + + + {t("matchScoreMissing")} + + {score.missingKeywords.length ? score.missingKeywords.map((k) => ) : {t("matchScoreAllCovered")}} + + + + {score.sectionCoverage.length ? ( + + {t("matchScoreSectionCoverage")} + + {score.sectionCoverage.map((s) => )} + + + ) : null} + + ); +} + function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) { const { t } = useI18n(); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index fdf618f..797b0c6 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -867,6 +867,20 @@ export const translations = { jobDetailsFollowUpSent: "Follow-up sent and logged.", jobDetailsFollowUpSendFailed: "Failed to send follow-up.", jobDetailsHowYouMatch: "How you match", + matchScoreTitle: "Match score", + matchScoreLoading: "Scoring your CV against this role…", + matchScoreBand_Strong: "Strong match", + matchScoreBand_Partial: "Partial match", + matchScoreBand_Low: "Low match", + matchScoreBand_Unknown: "Not enough signal", + matchScoreKeywordsCovered: "{matched}/{total} keywords", + matchScoreNoSignal: "Add more CV detail or a fuller job description to get a reliable score.", + matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable.", + matchScoreMatched: "Matched keywords", + matchScoreMissing: "Missing keywords", + matchScoreNoneYet: "No matches found yet.", + matchScoreAllCovered: "Every keyword is covered.", + matchScoreSectionCoverage: "Where your CV covers this role", jobDetailsStrategySnapshot: "Strategy snapshot", jobDetailsGenerateStrategySnapshot: "Generate strategy snapshot", jobDetailsStrategySnapshotEmpty: "Generate a snapshot to see fit, positioning, and immediate priorities in one place.", @@ -1784,6 +1798,20 @@ export const translations = { jobDetailsFollowUpSent: "Oppfølging sendt og loggført.", jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.", jobDetailsHowYouMatch: "Slik matcher du", + matchScoreTitle: "Match-score", + matchScoreLoading: "Vurderer CV-en mot denne stillingen…", + matchScoreBand_Strong: "Sterk match", + matchScoreBand_Partial: "Delvis match", + matchScoreBand_Low: "Lav match", + matchScoreBand_Unknown: "For lite grunnlag", + matchScoreKeywordsCovered: "{matched}/{total} nøkkelord", + matchScoreNoSignal: "Legg til mer CV-innhold eller en fyldigere stillingstekst for en pålitelig score.", + matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar.", + matchScoreMatched: "Treff på nøkkelord", + matchScoreMissing: "Manglende nøkkelord", + matchScoreNoneYet: "Ingen treff ennå.", + matchScoreAllCovered: "Alle nøkkelord er dekket.", + matchScoreSectionCoverage: "Hvor CV-en dekker denne rollen", jobDetailsStrategySnapshot: "Strategioversikt", jobDetailsGenerateStrategySnapshot: "Generer strategioversikt", jobDetailsStrategySnapshotEmpty: "Generer en oversikt for å se match, posisjonering og viktigste prioriteringer på ett sted.", diff --git a/job-tracker-ui/src/match-score-panel.test.tsx b/job-tracker-ui/src/match-score-panel.test.tsx new file mode 100644 index 0000000..f369a50 --- /dev/null +++ b/job-tracker-ui/src/match-score-panel.test.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ConfirmProvider } from './confirm'; +import { PromptProvider } from './prompt'; +import { ToastProvider } from './toast'; +import { I18nProvider } from './i18n/I18nProvider'; +import JobDetailsDialog from './components/JobDetailsDialog'; +import { api } from './api'; + +jest.setTimeout(15000); + +jest.mock('./api', () => ({ + api: { + get: jest.fn(), + post: jest.fn(() => Promise.resolve({ data: {} })), + put: jest.fn(() => Promise.resolve({ data: {} })), + patch: jest.fn(() => Promise.resolve({ data: {} })), + delete: jest.fn(() => Promise.resolve({ data: {} })), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, +})); + +const mockedApi = api as jest.Mocked; + +const matchScore = { + score: 82, + band: 'Strong', + matchedCount: 4, + totalKeywords: 6, + matchedKeywords: ['C#', '.NET', 'SQL', 'Docker'], + missingKeywords: ['Kubernetes', 'GraphQL'], + sectionCoverage: [ + { section: 'Skills', matched: 4, total: 6 }, + { section: 'Experience', matched: 3, total: 6 }, + ], + hasEnoughSignal: true, +}; + +function renderDialog() { + return render( + + + + + {}} initialTab={5} /> + + + + , + ); +} + +beforeEach(() => { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/jobapplications/42') { + return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any); + } + if (url === '/jobapplications/42/match-score') { + return Promise.resolve({ data: matchScore } as any); + } + if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any); + if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any); + if (url === '/attachments/42') return Promise.resolve({ data: [] } as any); + // Candidate-fit AI narrative: leave pending-ish/empty so we only assert on the fast panel. + if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any); + return Promise.resolve({ data: {} } as any); + }); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +test('match score panel shows the score, matched and missing keywords', async () => { + renderDialog(); + + expect(await screen.findByText('82%')).toBeInTheDocument(); + expect(await screen.findByText(/strong match/i)).toBeInTheDocument(); + expect(await screen.findByText('4/6 keywords')).toBeInTheDocument(); + + // Matched keyword chips + expect(await screen.findByText('C#')).toBeInTheDocument(); + expect(await screen.findByText('Docker')).toBeInTheDocument(); + + // Missing keyword chips + expect(await screen.findByText('Kubernetes')).toBeInTheDocument(); + expect(await screen.findByText('GraphQL')).toBeInTheDocument(); + + // Section coverage + expect(await screen.findByText('Skills: 4/6')).toBeInTheDocument(); +}); + +test('match score panel degrades gracefully when there is not enough signal', async () => { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/jobapplications/42') { + return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any); + } + if (url === '/jobapplications/42/match-score') { + return Promise.resolve({ data: { ...matchScore, score: 0, band: 'Unknown', matchedCount: 0, matchedKeywords: [], missingKeywords: [], sectionCoverage: [], hasEnoughSignal: false } } as any); + } + if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any); + if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any); + if (url === '/attachments/42') return Promise.resolve({ data: [] } as any); + if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any); + return Promise.resolve({ data: {} } as any); + }); + + renderDialog(); + + expect(await screen.findByText('—')).toBeInTheDocument(); + expect(await screen.findByText(/not enough signal/i)).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index fd70fc3..133d417 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -132,6 +132,23 @@ export interface CandidateFitChannelGuidance { recruiterMessage: string[]; } +export interface MatchScoreSectionCoverage { + section: string; + matched: number; + total: number; +} + +export interface MatchScore { + score: number; + band: string; + matchedCount: number; + totalKeywords: number; + matchedKeywords: string[]; + missingKeywords: string[]; + sectionCoverage: MatchScoreSectionCoverage[]; + hasEnoughSignal: boolean; +} + export interface CandidateFit { matchSummary: string; fitLevel: string; From bb736d118333a57d5d78b61c4b732eaa48f2aff8 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 03:31:35 +0200 Subject: [PATCH 14/27] feat: canonical job pipeline as single source of truth New JobPipeline: ordered canonical stages (Applied, Waiting, Interview, Offer, Rejected, Ghosted) with category grouping and a Normalize() that canonicalizes casing and known synonyms (Interviewing->Interview, declined->Rejected, ...) while preserving unknown custom statuses. - normalize status on every write path (Create/Update/PATCH status) so the stored value stays canonical without destroying custom values - GET /api/jobapplications/pipeline exposes the ordered stages so the UI renders from one source instead of duplicated hardcoded lists - 14 unit tests; full backend suite green (120) Co-Authored-By: Claude Fable 5 --- JobTrackerApi.Tests/JobPipelineTests.cs | 54 +++++++++++++ .../Controllers/JobApplicationsController.cs | 13 +++- JobTrackerApi/Services/JobPipeline.cs | 75 +++++++++++++++++++ 3 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 JobTrackerApi.Tests/JobPipelineTests.cs create mode 100644 JobTrackerApi/Services/JobPipeline.cs diff --git a/JobTrackerApi.Tests/JobPipelineTests.cs b/JobTrackerApi.Tests/JobPipelineTests.cs new file mode 100644 index 0000000..55b1a19 --- /dev/null +++ b/JobTrackerApi.Tests/JobPipelineTests.cs @@ -0,0 +1,54 @@ +using JobTrackerApi.Services; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class JobPipelineTests +{ + [Theory] + [InlineData("applied", "Applied")] + [InlineData("APPLIED", "Applied")] + [InlineData(" Offer ", "Offer")] + [InlineData("Interviewing", "Interview")] + [InlineData("interviews", "Interview")] + [InlineData("declined", "Rejected")] + [InlineData("no response", "Ghosted")] + public void Normalize_canonicalizes_casing_and_synonyms(string input, string expected) + => Assert.Equal(expected, JobPipeline.Normalize(input)); + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Normalize_empty_becomes_default(string? input) + => Assert.Equal("Applied", JobPipeline.Normalize(input)); + + [Fact] + public void Normalize_preserves_unknown_custom_status() + => Assert.Equal("Take-home assignment", JobPipeline.Normalize(" Take-home assignment ")); + + [Fact] + public void Stages_are_ordered_and_unique() + { + var orders = JobPipeline.Stages.Select(s => s.Order).ToList(); + Assert.Equal(orders.OrderBy(x => x), orders); + Assert.Equal(orders.Count, orders.Distinct().Count()); + } + + [Fact] + public void OrderOf_sorts_canonical_before_custom() + { + Assert.True(JobPipeline.OrderOf("Applied") < JobPipeline.OrderOf("Offer")); + Assert.True(JobPipeline.OrderOf("Offer") < JobPipeline.OrderOf("Custom stage")); + Assert.Equal(JobPipeline.OrderOf("Interview"), JobPipeline.OrderOf("Interviewing")); + } + + [Fact] + public void IsCanonical_only_true_for_known_stages() + { + Assert.True(JobPipeline.IsCanonical("Offer")); + Assert.True(JobPipeline.IsCanonical("offer")); + Assert.False(JobPipeline.IsCanonical("Interviewing")); // synonym, not canonical + Assert.False(JobPipeline.IsCanonical("Whatever")); + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 03deca7..2062d54 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1416,7 +1416,7 @@ Canonical profile: OwnerUserId = string.IsNullOrWhiteSpace(userId) ? null : userId, JobTitle = title, CompanyId = request.CompanyId, - Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(), + Status = JobPipeline.Normalize(request.Status), Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(), Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim(), NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(), @@ -1519,7 +1519,7 @@ Canonical profile: job.JobTitle = title; job.CompanyId = request.CompanyId; - job.Status = string.IsNullOrWhiteSpace(request.Status) ? job.Status : request.Status.Trim(); + job.Status = string.IsNullOrWhiteSpace(request.Status) ? job.Status : JobPipeline.Normalize(request.Status); job.ResponseReceived = request.ResponseReceived; job.ResponseDate = request.ResponseDate; job.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(); @@ -1572,6 +1572,13 @@ Canonical profile: public sealed record UpdateStatusRequest(string Status); + public sealed record PipelineStageDto(string Key, int Order, string Category); + + /// Canonical ordered pipeline stages so the UI renders one source of truth. + [HttpGet("pipeline")] + public ActionResult> GetPipeline() + => Ok(JobPipeline.Stages.Select(s => new PipelineStageDto(s.Key, s.Order, s.Category.ToString()))); + [HttpPatch("{id:int}/status")] public async Task UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken) { @@ -1580,7 +1587,7 @@ Canonical profile: if (string.IsNullOrWhiteSpace(request.Status)) return BadRequest("Status is required."); var old = job.Status; - job.Status = request.Status.Trim(); + job.Status = JobPipeline.Normalize(request.Status); if (!string.Equals(old, job.Status, StringComparison.OrdinalIgnoreCase)) { _db.JobEvents.Add(new JobEvent diff --git a/JobTrackerApi/Services/JobPipeline.cs b/JobTrackerApi/Services/JobPipeline.cs new file mode 100644 index 0000000..a4ca34e --- /dev/null +++ b/JobTrackerApi/Services/JobPipeline.cs @@ -0,0 +1,75 @@ +namespace JobTrackerApi.Services +{ + public enum PipelineCategory + { + Active, + Success, + Closed, + } + + public sealed record PipelineStage(string Key, int Order, PipelineCategory Category); + + /// + /// Canonical job-application pipeline: the single source of truth for the ordered set of + /// statuses, their grouping, and how free-text/legacy values normalize onto them. + /// Status remains a free-text column so custom values are never destroyed; this only + /// canonicalizes casing and known synonyms. + /// + public static class JobPipeline + { + public const string DefaultStatus = "Applied"; + + public static readonly IReadOnlyList Stages = new List + { + new("Applied", 1, PipelineCategory.Active), + new("Waiting", 2, PipelineCategory.Active), + new("Interview", 3, PipelineCategory.Active), + new("Offer", 4, PipelineCategory.Success), + new("Rejected", 5, PipelineCategory.Closed), + new("Ghosted", 6, PipelineCategory.Closed), + }; + + private static readonly Dictionary Canonical = + Stages.ToDictionary(s => s.Key, s => s.Key, StringComparer.OrdinalIgnoreCase); + + // Legacy/synonym spellings that should collapse onto a canonical stage. + private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase) + { + ["interviewing"] = "Interview", + ["interviews"] = "Interview", + ["interviewed"] = "Interview", + ["in interview"] = "Interview", + ["awaiting response"] = "Waiting", + ["awaiting"] = "Waiting", + ["in progress"] = "Waiting", + ["pending"] = "Waiting", + ["no response"] = "Ghosted", + ["no reply"] = "Ghosted", + ["declined"] = "Rejected", + }; + + /// + /// Returns the canonical status for a raw value: trims, matches a stage case-insensitively, + /// or maps a known synonym. Unknown non-empty values are preserved (trimmed) so custom + /// statuses survive. Empty/whitespace becomes the default stage. + /// + public static string Normalize(string? status) + { + var trimmed = (status ?? string.Empty).Trim(); + if (trimmed.Length == 0) return DefaultStatus; + if (Canonical.TryGetValue(trimmed, out var canonical)) return canonical; + if (Aliases.TryGetValue(trimmed, out var alias)) return alias; + return trimmed; + } + + public static bool IsCanonical(string? status) + => !string.IsNullOrWhiteSpace(status) && Canonical.ContainsKey(status.Trim()); + + public static int OrderOf(string? status) + { + var normalized = Normalize(status); + var stage = Stages.FirstOrDefault(s => string.Equals(s.Key, normalized, StringComparison.OrdinalIgnoreCase)); + return stage?.Order ?? int.MaxValue; // custom statuses sort last + } + } +} From 5a306f51a17585dacba65234c9b98eabefb87941 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 03:37:14 +0200 Subject: [PATCH 15/27] refactor(ui): drive status from a single shared pipeline module Introduces pipeline.ts (mirrors backend JobPipeline) as the one frontend source of truth for canonical stages, synonym normalization, tone, and localized labels. Replaces the status list/logic previously duplicated across KanbanBoard, JobTable, AddJobModal and EditJobDialog. - KanbanBoard/AddJobModal/EditJobDialog render from PIPELINE_STATUSES - JobTable uses shared statusTone + statusLabel (status chips now localized; NB gets proper labels, English unchanged) - Edit dialog status dropdown is now localized too - 5 unit tests; full frontend suite green (19 suites / 41 tests) Co-Authored-By: Claude Fable 5 --- README.md | 4 +- job-tracker-ui/src/components/AddJobModal.tsx | 20 ++---- .../src/components/EditJobDialog.tsx | 4 +- job-tracker-ui/src/components/JobTable.tsx | 24 +------ job-tracker-ui/src/components/KanbanBoard.tsx | 43 ++++--------- job-tracker-ui/src/pipeline.test.ts | 37 +++++++++++ job-tracker-ui/src/pipeline.ts | 64 +++++++++++++++++++ 7 files changed, 125 insertions(+), 71 deletions(-) create mode 100644 job-tracker-ui/src/pipeline.test.ts create mode 100644 job-tracker-ui/src/pipeline.ts diff --git a/README.md b/README.md index 127e26a..9a9cd89 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,9 @@ Authentication: - Updates an application; records a `StatusChanged` event if the status changed. - `PATCH /api/jobapplications/{id}/status` - Body: `{ "status": "..." }` - - Updates only status; records `StatusChanged` if it changed. + - Updates only status; records `StatusChanged` if it changed. The status is normalized against the canonical pipeline (casing + known synonyms like `Interviewing`→`Interview`); unrecognized values are preserved as custom statuses. +- `GET /api/jobapplications/pipeline` + - Returns the canonical ordered pipeline stages (`Applied, Waiting, Interview, Offer, Rejected, Ghosted`) with display order and category (`Active`/`Success`/`Closed`). The UI renders board columns and status dropdowns from this single source of truth. - `PATCH /api/jobapplications/{id}/followup` - Body: `{ "followUpAt": "2026-03-13T12:00:00Z" }` (or `null`) - Sets/clears follow-up date; records a `FollowUpSet` event. diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index c0668d9..c5592bf 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -30,6 +30,7 @@ import { Company, JobImportResult } from "../types"; import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; +import { PIPELINE_STATUSES, statusLabel as pipelineStatusLabel } from "../pipeline"; import TagsInput from "./TagsInput"; interface Props { @@ -60,7 +61,6 @@ type CreatedJobResponse = { type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other"; type AttachmentBuckets = Record; -const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; const ACCEPTED_DOCUMENT_TYPES = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown"; const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } }; const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX }; @@ -115,7 +115,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { const [dateApplied, setDateApplied] = useState(() => getTodayIso()); const [jobTitle, setJobTitle] = useState(""); - const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied"); + const [status, setStatus] = useState<(typeof PIPELINE_STATUSES)[number]>("Applied"); const [location, setLocation] = useState(""); const [salary, setSalary] = useState(""); const [salaryMin, setSalaryMin] = useState(""); @@ -350,18 +350,6 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { })); }; - const statusLabel = (value: typeof STATUS_OPTIONS[number]) => { - const map = { - Applied: t("statusApplied"), - Waiting: t("statusWaiting"), - Interview: t("statusInterview"), - Offer: t("statusOffer"), - Rejected: t("statusRejected"), - Ghosted: t("statusGhosted"), - } as const; - return map[value]; - }; - const filesLabel = (files: File[]) => { if (files.length === 0) return t("addJobModalNoFilesSelected"); if (files.length === 1) return files[0].name; @@ -479,9 +467,9 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { /> setStatus(e.target.value as any)} sx={FIELD_SX}> - {STATUS_OPTIONS.map((s) => ( + {PIPELINE_STATUSES.map((s) => ( - {statusLabel(s)} + {pipelineStatusLabel(t, s)} ))} diff --git a/job-tracker-ui/src/components/EditJobDialog.tsx b/job-tracker-ui/src/components/EditJobDialog.tsx index df0765b..a4f7102 100644 --- a/job-tracker-ui/src/components/EditJobDialog.tsx +++ b/job-tracker-ui/src/components/EditJobDialog.tsx @@ -24,6 +24,7 @@ import { useToast } from "../toast"; import { useCompanies } from "../hooks/useCompanies"; import TagsInput from "./TagsInput"; import { useI18n } from "../i18n/I18nProvider"; +import { PIPELINE_STATUSES, statusLabel } from "../pipeline"; interface Props { open: boolean; @@ -32,7 +33,6 @@ interface Props { onSaved: () => void; } -const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } }; const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX }; @@ -207,7 +207,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) {t("editJobStatusUpdate")} setStatus(e.target.value)} sx={FIELD_SX}> - {STATUS_OPTIONS.map((s) => {s})} + {PIPELINE_STATUSES.map((s) => {statusLabel(t, s)})} setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} /> setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /> diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index d0586ce..08518ce 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -45,6 +45,7 @@ import ViewStateNotice from "./ViewStateNotice"; import { useCompanies } from "../hooks/useCompanies"; import { useDebouncedValue } from "../hooks/useDebouncedValue"; import { formatSalary } from "../salary"; +import { statusLabel, statusTone } from "../pipeline"; import JobDetailsDialog from "./JobDetailsDialog"; import EditJobDialog from "./EditJobDialog"; import { useToast } from "../toast"; @@ -98,10 +99,6 @@ interface Props { mode?: "jobs" | "trash"; } -function normalizeStatus(status: string): string { - return status === "Interviewing" ? "Interview" : status; -} - function parseTags(raw?: string | null): string[] { if (!raw) return []; try { @@ -112,21 +109,6 @@ function parseTags(raw?: string | null): string[] { } } -function statusTone(status: string): string { - switch (normalizeStatus(status)) { - case "Offer": - return "success"; - case "Rejected": - return "error"; - case "Waiting": - case "Ghosted": - return "warning"; - case "Interview": - return "info"; - default: - return "primary"; - } -} function generateOverview(job: JobApplication): string { if (job.fullSummary) return job.fullSummary; @@ -547,7 +529,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col - {columns.status ? : null} + {columns.status ? : null} @@ -695,7 +677,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ))} - {columns.status ? : null} + {columns.status ? : null} {columns.dateApplied ? {appliedDateLabel} : null} {columns.daysSince ? {job.daysSince} : null} {columns.jobUrl ? {job.jobUrl ? {t("jobTableLink")} : ""} : null} diff --git a/job-tracker-ui/src/components/KanbanBoard.tsx b/job-tracker-ui/src/components/KanbanBoard.tsx index f386278..255ab62 100644 --- a/job-tracker-ui/src/components/KanbanBoard.tsx +++ b/job-tracker-ui/src/components/KanbanBoard.tsx @@ -19,41 +19,22 @@ import ViewStateNotice from "./ViewStateNotice"; import { JobApplication } from "../types"; import { useI18n } from "../i18n/I18nProvider"; import { useViewResource } from "../hooks/useViewResource"; +import { PIPELINE_STATUSES, PipelineStatus, normalizeStatus, statusLabel, statusTone } from "../pipeline"; -const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; -type Status = (typeof STATUSES)[number]; +const STATUSES = PIPELINE_STATUSES; +type Status = PipelineStatus; -function normalizeStatus(status: string): Status | "Other" { - if (status === "Interviewing") return "Interview"; - if ((STATUSES as readonly string[]).includes(status)) return status as Status; - return "Other"; -} +const TONE_PALETTE: Record string> = { + error: (theme) => theme.palette.error.main, + warning: (theme) => theme.palette.warning.main, + success: (theme) => theme.palette.success.main, + info: (theme) => alpha(theme.palette.primary.main, 0.95), + primary: (theme) => theme.palette.primary.main, + default: (theme) => theme.palette.primary.main, +}; function toneColor(theme: any, status: Status | "Other"): string { - if (status === "Rejected") return theme.palette.error.main; - if (status === "Waiting" || status === "Ghosted") return theme.palette.warning.main; - if (status === "Offer") return theme.palette.success.main; - if (status === "Interview") return alpha(theme.palette.primary.main, 0.95); - return theme.palette.primary.main; -} - -function statusLabel(t: (key: any, params?: any) => string, status: Status): string { - switch (status) { - case "Applied": - return t("statusApplied"); - case "Waiting": - return t("statusWaiting"); - case "Interview": - return t("statusInterview"); - case "Offer": - return t("statusOffer"); - case "Rejected": - return t("statusRejected"); - case "Ghosted": - return t("statusGhosted"); - default: - return status; - } + return TONE_PALETTE[statusTone(status)](theme); } export default function KanbanBoard() { diff --git a/job-tracker-ui/src/pipeline.test.ts b/job-tracker-ui/src/pipeline.test.ts new file mode 100644 index 0000000..6c6f3f6 --- /dev/null +++ b/job-tracker-ui/src/pipeline.test.ts @@ -0,0 +1,37 @@ +import { normalizeStatus, statusTone, statusLabel, PIPELINE_STATUSES } from './pipeline'; + +describe('pipeline', () => { + test('normalizeStatus canonicalizes casing and synonyms', () => { + expect(normalizeStatus('applied')).toBe('Applied'); + expect(normalizeStatus(' OFFER ')).toBe('Offer'); + expect(normalizeStatus('Interviewing')).toBe('Interview'); + expect(normalizeStatus('declined')).toBe('Rejected'); + }); + + test('normalizeStatus preserves unknown as Other and empty as Applied', () => { + expect(normalizeStatus('Take-home')).toBe('Other'); + expect(normalizeStatus('')).toBe('Applied'); + expect(normalizeStatus(null)).toBe('Applied'); + }); + + test('statusTone maps stages to palette keys', () => { + expect(statusTone('Offer')).toBe('success'); + expect(statusTone('Rejected')).toBe('error'); + expect(statusTone('Waiting')).toBe('warning'); + expect(statusTone('Ghosted')).toBe('warning'); + expect(statusTone('Interview')).toBe('info'); + expect(statusTone('Applied')).toBe('primary'); + expect(statusTone('Take-home')).toBe('default'); + }); + + test('statusLabel localizes canonical and passes through custom', () => { + const t = (key: string) => ({ statusApplied: 'Applied', statusOffer: 'Offer' } as Record)[key] ?? key; + expect(statusLabel(t, 'Applied')).toBe('Applied'); + expect(statusLabel(t, 'Interviewing')).toBe('statusInterview'); // maps to canonical key + expect(statusLabel(t, 'Take-home assignment')).toBe('Take-home assignment'); + }); + + test('canonical stage list is stable and ordered', () => { + expect(PIPELINE_STATUSES).toEqual(['Applied', 'Waiting', 'Interview', 'Offer', 'Rejected', 'Ghosted']); + }); +}); diff --git a/job-tracker-ui/src/pipeline.ts b/job-tracker-ui/src/pipeline.ts new file mode 100644 index 0000000..9e61fc4 --- /dev/null +++ b/job-tracker-ui/src/pipeline.ts @@ -0,0 +1,64 @@ +// Single frontend source of truth for the canonical job pipeline. +// Mirrors the backend JobPipeline (JobTrackerApi/Services/JobPipeline.cs); keep the two in sync. + +export const PIPELINE_STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; + +export type PipelineStatus = (typeof PIPELINE_STATUSES)[number]; + +export type StatusTone = "primary" | "info" | "success" | "warning" | "error" | "default"; + +// Legacy/synonym spellings collapse onto a canonical stage (matches the backend alias map). +const ALIASES: Record = { + interviewing: "Interview", + interviews: "Interview", + interviewed: "Interview", + declined: "Rejected", + "no response": "Ghosted", + "no reply": "Ghosted", + pending: "Waiting", + "awaiting response": "Waiting", +}; + +/** Canonical status for a raw value, or "Other" for unknown/custom statuses. */ +export function normalizeStatus(status?: string | null): PipelineStatus | "Other" { + const trimmed = (status ?? "").trim(); + if (!trimmed) return "Applied"; + const exact = PIPELINE_STATUSES.find((s) => s.toLowerCase() === trimmed.toLowerCase()); + if (exact) return exact; + const alias = ALIASES[trimmed.toLowerCase()]; + return alias ?? "Other"; +} + +/** MUI palette key for a status; both chip color and board accent derive from this. */ +export function statusTone(status?: string | null): StatusTone { + switch (normalizeStatus(status)) { + case "Offer": + return "success"; + case "Rejected": + return "error"; + case "Waiting": + case "Ghosted": + return "warning"; + case "Interview": + return "info"; + case "Applied": + return "primary"; + default: + return "default"; + } +} + +const LABEL_KEYS: Record = { + Applied: "statusApplied", + Waiting: "statusWaiting", + Interview: "statusInterview", + Offer: "statusOffer", + Rejected: "statusRejected", + Ghosted: "statusGhosted", +}; + +/** Localized label for a status, falling back to the raw value for custom statuses. */ +export function statusLabel(t: (key: any, params?: any) => string, status: string): string { + const normalized = normalizeStatus(status); + return normalized === "Other" ? status : t(LABEL_KEYS[normalized]); +} From 45cbc8b1ab5a18725e45a2818d6573aa83964051 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 03:40:41 +0200 Subject: [PATCH 16/27] feat: time-in-stage analytics + pipeline-driven funnel - New pure StageAnalytics.TimeInStage: median days jobs have spent in each active pipeline stage (entry time from the last StatusChanged event into that stage, else applied date). Closed/success stages excluded since 'how long stuck' only applies to actionable stages. - analytics-overview now derives the funnel from JobPipeline (includes the previously-omitted Waiting stage, normalizes legacy spellings) and returns TimeInStage. - 4 unit tests; full backend suite green (124). Co-Authored-By: Claude Fable 5 --- JobTrackerApi.Tests/StageAnalyticsTests.cs | 62 +++++++++++++++++++ .../Controllers/JobApplicationsController.cs | 57 +++++++++++++---- JobTrackerApi/Services/StageAnalytics.cs | 45 ++++++++++++++ 3 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 JobTrackerApi.Tests/StageAnalyticsTests.cs create mode 100644 JobTrackerApi/Services/StageAnalytics.cs diff --git a/JobTrackerApi.Tests/StageAnalyticsTests.cs b/JobTrackerApi.Tests/StageAnalyticsTests.cs new file mode 100644 index 0000000..46dc241 --- /dev/null +++ b/JobTrackerApi.Tests/StageAnalyticsTests.cs @@ -0,0 +1,62 @@ +using JobTrackerApi.Services; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class StageAnalyticsTests +{ + private static readonly DateTime Now = new(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Computes_median_days_per_active_stage() + { + var jobs = new[] + { + new StageOccupancy("Applied", Now.AddDays(-10)), + new StageOccupancy("Applied", Now.AddDays(-20)), + new StageOccupancy("Applied", Now.AddDays(-30)), + new StageOccupancy("Interview", Now.AddDays(-4)), + }; + + var result = StageAnalytics.TimeInStage(jobs, Now); + + var applied = Assert.Single(result, p => p.Stage == "Applied"); + Assert.Equal(20, applied.MedianDays); + Assert.Equal(3, applied.Count); + + var interview = Assert.Single(result, p => p.Stage == "Interview"); + Assert.Equal(4, interview.MedianDays); + } + + [Fact] + public void Excludes_closed_and_success_stages() + { + var jobs = new[] + { + new StageOccupancy("Offer", Now.AddDays(-5)), + new StageOccupancy("Rejected", Now.AddDays(-5)), + new StageOccupancy("Ghosted", Now.AddDays(-5)), + }; + + Assert.Empty(StageAnalytics.TimeInStage(jobs, Now)); + } + + [Fact] + public void Normalizes_legacy_status_and_orders_by_pipeline() + { + var jobs = new[] + { + new StageOccupancy("Interviewing", Now.AddDays(-3)), + new StageOccupancy("Applied", Now.AddDays(-1)), + new StageOccupancy("Waiting", Now.AddDays(-2)), + }; + + var result = StageAnalytics.TimeInStage(jobs, Now); + + Assert.Equal(new[] { "Applied", "Waiting", "Interview" }, result.Select(p => p.Stage).ToArray()); + } + + [Fact] + public void Empty_input_returns_empty() + => Assert.Empty(StageAnalytics.TimeInStage(Array.Empty(), Now)); +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 2062d54..87a1ec5 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -2023,13 +2023,15 @@ Canonical profile: public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate); public sealed record TagTrendSeries(string Tag, List Counts); public sealed record TagTrendPoint(string Month, List Counts); + public sealed record StageDurationDto(string Stage, double MedianDays, int Count); public sealed record AnalyticsOverviewDto( List Funnel, List ResponseRateBySource, List TopCompanies, double? MedianDaysToFirstResponse, int TotalResponses, - int TotalActive + int TotalActive, + List TimeInStage ); public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason); public sealed record DuplicateCheckResult(bool HasDuplicates, List Matches); @@ -2803,16 +2805,14 @@ Candidate master CV: .Where(j => !j.IsDeleted) .ToListAsync(cancellationToken); - var funnelMap = new Dictionary - { - ["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)), - ["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)), - ["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)), - ["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)), - ["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)), - }; - - var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList(); + // Funnel = distribution across canonical stages, driven by the pipeline (one source + // of truth, so it includes every stage and normalizes legacy spellings). + var normalizedByStage = activeJobs + .GroupBy(j => JobPipeline.Normalize(j.Status)) + .ToDictionary(g => g.Key, g => g.Count()); + var funnel = JobPipeline.Stages + .Select(stage => new FunnelStagePoint(stage.Key, normalizedByStage.TryGetValue(stage.Key, out var c) ? c : 0)) + .ToList(); var responseRateBySource = activeJobs .GroupBy(j => string.IsNullOrWhiteSpace(j.Company?.Source) ? "Unknown source" : j.Company!.Source!.Trim()) @@ -2856,13 +2856,46 @@ Candidate master CV: : Math.Round(responseDays[mid], 1); } + // Time-in-stage: for each active job, when did it enter its current stage? Use the most + // recent StatusChanged event into that stage, else its applied date. + var activeIds = activeJobs.Select(j => j.Id).ToList(); + var statusChanges = await _db.JobEvents + .AsNoTracking() + .Where(e => e.Type == "StatusChanged" && activeIds.Contains(e.JobApplicationId)) + .Select(e => new { e.JobApplicationId, e.NewValue, e.At }) + .ToListAsync(cancellationToken); + + var lastEntryByJob = statusChanges + .GroupBy(e => e.JobApplicationId) + .ToDictionary(g => g.Key, g => g.ToList()); + + var occupancy = activeJobs.Select(job => + { + var current = JobPipeline.Normalize(job.Status); + DateTime enteredAt = job.DateApplied; + if (lastEntryByJob.TryGetValue(job.Id, out var changes)) + { + var lastIntoCurrent = changes + .Where(e => JobPipeline.Normalize(e.NewValue) == current) + .OrderByDescending(e => e.At) + .FirstOrDefault(); + if (lastIntoCurrent is not null) enteredAt = lastIntoCurrent.At; + } + return new StageOccupancy(current, enteredAt.ToUniversalTime()); + }); + + var timeInStage = StageAnalytics.TimeInStage(occupancy, DateTime.UtcNow) + .Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count)) + .ToList(); + return Ok(new AnalyticsOverviewDto( Funnel: funnel, ResponseRateBySource: responseRateBySource, TopCompanies: topCompanies, MedianDaysToFirstResponse: medianDays, TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null), - TotalActive: activeJobs.Count + TotalActive: activeJobs.Count, + TimeInStage: timeInStage )); } diff --git a/JobTrackerApi/Services/StageAnalytics.cs b/JobTrackerApi/Services/StageAnalytics.cs new file mode 100644 index 0000000..d6a552c --- /dev/null +++ b/JobTrackerApi/Services/StageAnalytics.cs @@ -0,0 +1,45 @@ +namespace JobTrackerApi.Services +{ + public sealed record StageDurationPoint(string Stage, int Order, double MedianDays, int Count); + + /// One job's position: its canonical stage and when it entered that stage. + public sealed record StageOccupancy(string Status, DateTime EnteredStageAtUtc); + + /// + /// Pure time-in-stage analytics: for each active pipeline stage, the median number of days + /// the jobs currently sitting there have been waiting. Closed stages (Rejected/Ghosted) and + /// the terminal success stage (Offer) are excluded — "how long has this been stuck" only + /// makes sense for stages you still act on. + /// + public static class StageAnalytics + { + public static List TimeInStage(IEnumerable jobs, DateTime nowUtc) + { + var byStage = jobs + .Select(j => (Stage: JobPipeline.Normalize(j.Status), Days: Math.Max(0, (nowUtc - j.EnteredStageAtUtc).TotalDays))) + .Where(x => JobPipeline.Stages.Any(s => s.Key == x.Stage && s.Category == PipelineCategory.Active)) + .GroupBy(x => x.Stage); + + var points = new List(); + foreach (var group in byStage) + { + var days = group.Select(x => x.Days).OrderBy(x => x).ToList(); + points.Add(new StageDurationPoint( + Stage: group.Key, + Order: JobPipeline.OrderOf(group.Key), + MedianDays: Median(days), + Count: days.Count)); + } + + return points.OrderBy(p => p.Order).ToList(); + } + + private static double Median(IReadOnlyList sorted) + { + if (sorted.Count == 0) return 0; + var mid = sorted.Count / 2; + var median = sorted.Count % 2 == 0 ? (sorted[mid - 1] + sorted[mid]) / 2d : sorted[mid]; + return Math.Round(median, 1); + } + } +} From 695fbd6d21608c91b0d4f0cc6478b8b09b236ba8 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 03:43:17 +0200 Subject: [PATCH 17/27] feat(ui): time-in-stage on the dashboard + localized funnel labels - Adds a 'Median time in stage' block to the conversion-funnel card showing median days and active count per stage from the enriched analytics-overview endpoint. - Funnel bar labels and stage names now render through the shared pipeline statusLabel (localized; the funnel also now includes Waiting). - EN/NB translations. Full frontend suite green (20 suites / 46 tests). Co-Authored-By: Claude Fable 5 --- .../src/components/DashboardView.tsx | 20 ++++++++++++++++++- job-tracker-ui/src/i18n/translations.ts | 4 ++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index f799268..a99d21c 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -25,6 +25,7 @@ import { api } from "../api"; import ViewStateNotice from "./ViewStateNotice"; import { getUserKeyFromToken } from "../themePrefs"; import { useI18n } from "../i18n/I18nProvider"; +import { statusLabel } from "../pipeline"; import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals"; import { JobApplication } from "../types"; import { useViewResource } from "../hooks/useViewResource"; @@ -49,6 +50,7 @@ type OverviewAnalytics = { medianDaysToFirstResponse?: number | null; totalResponses: number; totalActive: number; + timeInStage?: { stage: string; medianDays: number; count: number }[]; }; type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] }; @@ -453,7 +455,7 @@ export default function DashboardView() { return ( - {item.label} + {statusLabel(t, item.label)} {item.count} + {overview?.timeInStage?.length ? ( + + {t("dashboardTimeInStageTitle")} + + {overview.timeInStage.map((item) => ( + + {statusLabel(t, item.stage)} + + {t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })} + + + ))} + + + ) : null} + {summaryView.topSource?.label ?? t("dashboardResponseSources")} {summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 797b0c6..b2fbd04 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -335,6 +335,8 @@ export const translations = { dashboardApplicationActivity: "Application activity", dashboardMonthlyApplicationsResponses: "Monthly applications versus responses.", dashboardConversionFunnelTitle: "Conversion funnel", + dashboardTimeInStageTitle: "Median time in stage", + dashboardTimeInStageValue: "{days}d · {count} active", dashboardResponseSources: "Response sources", dashboardTopCompaniesByActivity: "Top companies by activity", dashboardTopSkills: "Top skills", @@ -1266,6 +1268,8 @@ export const translations = { dashboardApplicationActivity: "Søknadsaktivitet", dashboardMonthlyApplicationsResponses: "Månedlige søknader versus svar.", dashboardConversionFunnelTitle: "Konverteringstrakt", + dashboardTimeInStageTitle: "Median tid i fase", + dashboardTimeInStageValue: "{days}d · {count} aktive", dashboardResponseSources: "Svar etter kilde", dashboardTopCompaniesByActivity: "Topp selskaper etter aktivitet", dashboardTopSkills: "Topp ferdigheter", From ae3505b877f033c04465adb3bcdc749aff0241c9 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 03:48:32 +0200 Subject: [PATCH 18/27] feat: deterministic email-driven status suggestions New EmailStatusClassifier scans a message subject/body for outcome signals (interview invite, offer, rejection) and suggests a canonical pipeline status. Priority-ordered so a rejection that mentions the prior interview still classifies as Rejected. Deterministic - no AI - so it is instant, reproducible, and safe. - GET /api/jobapplications/{id}/status-suggestion reads the job's latest inbound correspondence (incl. Gmail imports) and suggests a forward status move, suppressed when already in/past that stage - always human-confirmed via the existing PATCH .../status - 7 classifier unit tests + 2 endpoint integration tests; backend green (133) Co-Authored-By: Claude Fable 5 --- .../EmailStatusClassifierTests.cs | 61 ++++++++++++++++++ .../JobApplicationsEndpointBehaviorTests.cs | 62 +++++++++++++++++++ .../Controllers/JobApplicationsController.cs | 51 +++++++++++++++ .../Services/EmailStatusClassifier.cs | 61 ++++++++++++++++++ 4 files changed, 235 insertions(+) create mode 100644 JobTrackerApi.Tests/EmailStatusClassifierTests.cs create mode 100644 JobTrackerApi/Services/EmailStatusClassifier.cs diff --git a/JobTrackerApi.Tests/EmailStatusClassifierTests.cs b/JobTrackerApi.Tests/EmailStatusClassifierTests.cs new file mode 100644 index 0000000..1939932 --- /dev/null +++ b/JobTrackerApi.Tests/EmailStatusClassifierTests.cs @@ -0,0 +1,61 @@ +using JobTrackerApi.Services; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class EmailStatusClassifierTests +{ + [Fact] + public void Detects_rejection() + { + var s = EmailStatusClassifier.Classify("Your application", "Thank you for your time. Unfortunately, we have decided not to proceed with your application."); + Assert.NotNull(s); + Assert.Equal("Rejected", s!.SuggestedStatus); + } + + [Fact] + public void Detects_offer() + { + var s = EmailStatusClassifier.Classify("Great news", "We are pleased to offer you the position of Backend Engineer."); + Assert.NotNull(s); + Assert.Equal("Offer", s!.SuggestedStatus); + } + + [Fact] + public void Detects_interview_invite() + { + var s = EmailStatusClassifier.Classify("Next steps", "We would like to invite you to interview next week. What is your availability for a call?"); + Assert.NotNull(s); + Assert.Equal("Interview", s!.SuggestedStatus); + } + + [Fact] + public void Rejection_wins_over_interview_mention() + { + // A rejection email that references the interview the candidate had must classify as Rejected. + var s = EmailStatusClassifier.Classify( + "Update on your application", + "Thank you for taking the time to interview with us. Unfortunately, we will not be moving forward."); + Assert.NotNull(s); + Assert.Equal("Rejected", s!.SuggestedStatus); + } + + [Fact] + public void Weak_interview_cue_is_low_confidence() + { + var s = EmailStatusClassifier.Classify("Coding challenge", "Please complete this take-home assessment."); + Assert.NotNull(s); + Assert.Equal("Interview", s!.SuggestedStatus); + Assert.Equal("low", s.Confidence); + } + + [Fact] + public void Returns_null_for_neutral_email() + { + Assert.Null(EmailStatusClassifier.Classify("Re: question", "Thanks for the info, that answers my question about the parking.")); + } + + [Fact] + public void Handles_empty_input() + => Assert.Null(EmailStatusClassifier.Classify(null, null)); +} diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index 0592b72..95c0b8d 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -56,6 +56,68 @@ public sealed class JobApplicationsEndpointBehaviorTests Assert.Contains("Profile page", badRequest.Value?.ToString()); } + [Fact] + public async Task Status_suggestion_from_latest_inbound_rejection() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Applied" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + db.Correspondences.Add(new Correspondence + { + JobApplicationId = job.Id, + From = "Company", + Direction = "inbound", + Subject = "Update", + Content = "Unfortunately, we have decided not to proceed.", + Date = DateTime.Now, + }); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.True(dto.HasSuggestion); + Assert.Equal("Rejected", dto.SuggestedStatus); + } + + [Fact] + public async Task Status_suggestion_suppressed_when_already_in_stage() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Rejected" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + db.Correspondences.Add(new Correspondence + { + JobApplicationId = job.Id, + From = "Company", + Direction = "inbound", + Content = "Unfortunately, we will not be moving forward.", + Date = DateTime.Now, + }); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.False(dto.HasSuggestion); + } + [Fact] public async Task Match_score_scores_job_against_profile_cv() { diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 87a1ec5..4f0ba63 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1604,6 +1604,57 @@ Canonical profile: return NoContent(); } + public sealed record StatusSuggestionDto( + bool HasSuggestion, + string? SuggestedStatus, + string? CurrentStatus, + string? Signal, + string? Confidence, + DateTime? MessageDate, + string? MessageSubject); + + /// + /// Suggests a pipeline status from the job's most recent inbound message (e.g. an interview + /// invite or rejection). Deterministic and always human-confirmed via PATCH .../status. + /// + [HttpGet("{id:int}/status-suggestion")] + public async Task> GetStatusSuggestion([FromRoute] int id, CancellationToken cancellationToken) + { + var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var none = new StatusSuggestionDto(false, null, job.Status, null, null, null, null); + + var latestInbound = await _db.Correspondences + .AsNoTracking() + .Where(c => c.JobApplicationId == id + && c.Direction != "outbound" + && c.From != "Me") + .OrderByDescending(c => c.Date) + .FirstOrDefaultAsync(cancellationToken); + if (latestInbound is null) return Ok(none); + + var suggestion = EmailStatusClassifier.Classify(latestInbound.Subject, latestInbound.Content); + if (suggestion is null) return Ok(none); + + // Don't nag when the job is already in (or past) the suggested stage. + var currentOrder = JobPipeline.OrderOf(job.Status); + var suggestedOrder = JobPipeline.OrderOf(suggestion.SuggestedStatus); + if (JobPipeline.Normalize(job.Status) == suggestion.SuggestedStatus || currentOrder >= suggestedOrder) + { + return Ok(none); + } + + return Ok(new StatusSuggestionDto( + HasSuggestion: true, + SuggestedStatus: suggestion.SuggestedStatus, + CurrentStatus: job.Status, + Signal: suggestion.Signal, + Confidence: suggestion.Confidence, + MessageDate: latestInbound.Date, + MessageSubject: latestInbound.Subject)); + } + [HttpPost("{id:int}/refresh-ai")] public async Task> RefreshAi([FromRoute] int id, CancellationToken cancellationToken) diff --git a/JobTrackerApi/Services/EmailStatusClassifier.cs b/JobTrackerApi/Services/EmailStatusClassifier.cs new file mode 100644 index 0000000..b5108c1 --- /dev/null +++ b/JobTrackerApi/Services/EmailStatusClassifier.cs @@ -0,0 +1,61 @@ +namespace JobTrackerApi.Services +{ + public sealed record EmailStatusSuggestion(string SuggestedStatus, string Signal, string Confidence); + + /// + /// Deterministic email → pipeline-status classifier. Scans subject/body for outcome signals and + /// suggests a canonical status. No AI: instant, reproducible, and safe (the user always confirms). + /// Priority matters — a rejection email often still mentions "interview", so rejection wins. + /// + public static class EmailStatusClassifier + { + // Ordered highest-priority first. Each stage lists lowercase phrases to look for. + private static readonly (string Status, string Confidence, string[] Phrases)[] Rules = + { + ("Rejected", "high", new[] + { + "regret to inform", "we regret", "unfortunately, we", "not moving forward", + "not be moving forward", "decided not to proceed", "will not be proceeding", + "not to proceed", "not been selected", "will not be progressing", + "unable to offer", "position has been filled", "no longer being considered", + "decided to move forward with other", "pursue other candidates", + "not to move forward", "were not successful", "was not successful", + }), + ("Offer", "high", new[] + { + "pleased to offer", "delighted to offer", "happy to offer", "offer of employment", + "job offer", "we would like to offer", "formal offer", "extend an offer", + "offer letter", "excited to offer", + }), + ("Interview", "medium", new[] + { + "invite you to interview", "invite you to an interview", "schedule an interview", + "would like to invite you", "phone screen", "phone interview", "video interview", + "technical interview", "next steps in the", "your availability for a call", + "availability for an interview", "set up a call", "set up an interview", + "meet the team", "book a time", "invitation to interview", "interview invitation", + "like to speak with you", "move to the interview", + }), + }; + + // Weaker single-word cues only fire when no strong phrase matched (kept low-confidence). + private static readonly string[] InterviewWeakCues = { "interview", "assessment", "coding challenge", "take-home" }; + + public static EmailStatusSuggestion? Classify(string? subject, string? body) + { + var text = $"{subject}\n{body}".ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(text)) return null; + + foreach (var (status, confidence, phrases) in Rules) + { + var hit = phrases.FirstOrDefault(p => text.Contains(p, StringComparison.Ordinal)); + if (hit is not null) return new EmailStatusSuggestion(status, hit, confidence); + } + + var weak = InterviewWeakCues.FirstOrDefault(c => text.Contains(c, StringComparison.Ordinal)); + if (weak is not null) return new EmailStatusSuggestion("Interview", weak, "low"); + + return null; + } + } +} From a1a3736cc4e91a42e0cf98eb419282819993004c Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 03:52:57 +0200 Subject: [PATCH 19/27] feat(ui): human-confirmed status suggestion banner When a job workspace opens, loads /status-suggestion and shows a dismissible banner when a recent inbound email implies a status move ("This email looks like a move to Interview"). Applying it PATCHes the status; nothing changes without the user's click. - StatusSuggestion type + load-on-open effect + apply handler - warning-toned banner shown above tab content on any tab - EN/NB translations; README endpoint docs - 2 frontend tests; full suite green (21 suites / 48 tests) Co-Authored-By: Claude Fable 5 --- README.md | 2 + .../src/components/JobDetailsDialog.tsx | 51 ++++++++++- job-tracker-ui/src/i18n/translations.ts | 12 +++ job-tracker-ui/src/status-suggestion.test.tsx | 90 +++++++++++++++++++ job-tracker-ui/src/types.ts | 10 +++ 5 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 job-tracker-ui/src/status-suggestion.test.tsx diff --git a/README.md b/README.md index 9a9cd89..55cd4aa 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,8 @@ Authentication: - Returns totals, counts by status, applied-last-30-days, and average days since applied. - `GET /api/jobapplications/{id}/match-score` - Deterministic CV↔job keyword-coverage score (0–100) with matched/missing keywords and per-CV-section coverage. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is `GET /api/jobapplications/{id}/candidate-fit`.) +- `GET /api/jobapplications/{id}/status-suggestion` + - Deterministic status suggestion derived from the job's most recent inbound message (interview invite / offer / rejection). Returns a forward-only suggestion (`hasSuggestion`, `suggestedStatus`, `signal`, …) or `hasSuggestion: false`. Applying it is a normal `PATCH .../status` — always user-confirmed. - `DELETE /api/jobapplications/{id}` - Soft-deletes an application (`IsDeleted=true`); records a `Deleted` event. - `POST /api/jobapplications/{id}/restore` diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index b7b791f..c64e741 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -18,9 +18,11 @@ import { TextField, Typography, } from "@mui/material"; +import { alpha } from "@mui/material/styles"; import { api, getApiErrorMessage } from "../api"; -import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, TailoredCvDraft } from "../types"; +import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, StatusSuggestion, TailoredCvDraft } from "../types"; +import { statusLabel } from "../pipeline"; import { useToast } from "../toast"; import { useDialogActions } from "../dialogs"; import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft"; @@ -172,6 +174,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const [candidateFit, setCandidateFit] = useState(null); const [matchScore, setMatchScore] = useState(null); const [loadingMatchScore, setLoadingMatchScore] = useState(false); + const [statusSuggestion, setStatusSuggestion] = useState(null); + const [applyingStatusSuggestion, setApplyingStatusSuggestion] = useState(false); const [focusPlan, setFocusPlan] = useState(null); const [loadingCandidateFit, setLoadingCandidateFit] = useState(false); const [loadingFocusPlan, setLoadingFocusPlan] = useState(false); @@ -205,6 +209,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, setFollowUpDraft(null); setCandidateFit(null); setMatchScore(null); + setStatusSuggestion(null); setFocusPlan(null); setInterviewPrep(null); setReadiness(null); @@ -303,6 +308,31 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, }).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false)); }, [open, jobId, tab, matchScore, matchScoreCache]); + // Suggest a status move from the latest inbound email when the workspace opens. + useEffect(() => { + if (!open || !jobId) return; + let cancelled = false; + api.get(`/jobapplications/${jobId}/status-suggestion`) + .then((r) => { if (!cancelled) setStatusSuggestion(r.data?.hasSuggestion ? r.data : null); }) + .catch(() => { if (!cancelled) setStatusSuggestion(null); }); + return () => { cancelled = true; }; + }, [open, jobId]); + + const applyStatusSuggestion = async () => { + if (!jobId || !statusSuggestion?.suggestedStatus) return; + setApplyingStatusSuggestion(true); + try { + await api.patch(`/jobapplications/${jobId}/status`, { status: statusSuggestion.suggestedStatus }); + setJob((prev) => prev ? { ...prev, status: statusSuggestion.suggestedStatus! } : prev); + setStatusSuggestion(null); + toast(t("statusSuggestionApplied"), "success"); + } catch (error: any) { + toast(getApiErrorMessage(error, t("statusSuggestionFailed")), "error"); + } finally { + setApplyingStatusSuggestion(false); + } + }; + useEffect(() => { if (!open || !jobId || tab !== 6 || focusPlan) return; const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`; @@ -621,6 +651,25 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {attachmentPicker} + {statusSuggestion?.hasSuggestion ? ( + alpha(theme.palette.warning.main, 0.08), display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}> + + + {t("statusSuggestionTitle", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })} + + + {t("statusSuggestionReason", { signal: statusSuggestion.signal ?? "", current: statusLabel(t, statusSuggestion.currentStatus ?? "") })} + + + + + + + + ) : null} + {tab === 0 && ( diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index b2fbd04..6c72577 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -781,6 +781,12 @@ export const translations = { jobDetailsTabFocusPlan: "Focus plan", jobDetailsTabInterviewPrep: "Interview prep", jobDetailsTabHistory: "History", + statusSuggestionTitle: "This email looks like a move to {status}", + statusSuggestionReason: "Matched \"{signal}\" · currently {current}", + statusSuggestionApply: "Move to {status}", + statusSuggestionDismiss: "Dismiss", + statusSuggestionApplied: "Status updated.", + statusSuggestionFailed: "Could not update status.", jobDetailsTailoredCvMode: "Generation mode", jobDetailsGenerationDefault: "Balanced", jobDetailsGenerationConcise: "Concise", @@ -1714,6 +1720,12 @@ export const translations = { jobDetailsTabFocusPlan: "Fokusplan", jobDetailsTabInterviewPrep: "Intervjuforberedelse", jobDetailsTabHistory: "Historikk", + statusSuggestionTitle: "Denne e-posten ser ut som en overgang til {status}", + statusSuggestionReason: "Traff \"{signal}\" · nå {current}", + statusSuggestionApply: "Flytt til {status}", + statusSuggestionDismiss: "Avvis", + statusSuggestionApplied: "Status oppdatert.", + statusSuggestionFailed: "Kunne ikke oppdatere status.", jobDetailsTailoredCvMode: "Genereringsmodus", jobDetailsGenerationDefault: "Balansert", jobDetailsGenerationConcise: "Kortfattet", diff --git a/job-tracker-ui/src/status-suggestion.test.tsx b/job-tracker-ui/src/status-suggestion.test.tsx new file mode 100644 index 0000000..d36d2d8 --- /dev/null +++ b/job-tracker-ui/src/status-suggestion.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ConfirmProvider } from './confirm'; +import { PromptProvider } from './prompt'; +import { ToastProvider } from './toast'; +import { I18nProvider } from './i18n/I18nProvider'; +import JobDetailsDialog from './components/JobDetailsDialog'; +import { api } from './api'; + +jest.setTimeout(15000); + +jest.mock('./api', () => ({ + api: { + get: jest.fn(), + post: jest.fn(() => Promise.resolve({ data: {} })), + put: jest.fn(() => Promise.resolve({ data: {} })), + patch: jest.fn(() => Promise.resolve({ data: {} })), + delete: jest.fn(() => Promise.resolve({ data: {} })), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, + getApiErrorMessage: jest.fn(() => 'error'), +})); + +const mockedApi = api as jest.Mocked; + +function renderDialog() { + return render( + + + + + {}} /> + + + + , + ); +} + +beforeEach(() => { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/jobapplications/42') { + return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any); + } + if (url === '/jobapplications/42/status-suggestion') { + return Promise.resolve({ data: { hasSuggestion: true, suggestedStatus: 'Interview', currentStatus: 'Applied', signal: 'schedule an interview', confidence: 'medium' } } as any); + } + if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any); + if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any); + if (url === '/attachments/42') return Promise.resolve({ data: [] } as any); + return Promise.resolve({ data: {} } as any); + }); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +test('status suggestion banner appears and applies via PATCH', async () => { + renderDialog(); + + expect(await screen.findByText(/looks like a move to interview/i)).toBeInTheDocument(); + + fireEvent.click(await screen.findByRole('button', { name: /move to interview/i })); + + await waitFor(() => { + expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/42/status', { status: 'Interview' }); + }); +}); + +test('no banner when there is no suggestion', async () => { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/jobapplications/42') { + return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any); + } + if (url === '/jobapplications/42/status-suggestion') { + return Promise.resolve({ data: { hasSuggestion: false } } as any); + } + if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any); + if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any); + if (url === '/attachments/42') return Promise.resolve({ data: [] } as any); + return Promise.resolve({ data: {} } as any); + }); + + renderDialog(); + + expect(await screen.findByText(/backend developer/i)).toBeInTheDocument(); + expect(screen.queryByText(/looks like a move to/i)).not.toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index 133d417..9efd0d6 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -138,6 +138,16 @@ export interface MatchScoreSectionCoverage { total: number; } +export interface StatusSuggestion { + hasSuggestion: boolean; + suggestedStatus?: string | null; + currentStatus?: string | null; + signal?: string | null; + confidence?: string | null; + messageDate?: string | null; + messageSubject?: string | null; +} + export interface MatchScore { score: number; band: string; From bd51c245d3dbeae4baa4c18a6a64d4be4e8838f7 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 03:55:12 +0200 Subject: [PATCH 20/27] test(security): lock tenant isolation on match-score and status-suggestion Cross-user access to the new endpoints returns NotFound (carried by the JobTrackerContext global query filters). Regression guard for the class of tenant-leak bugs found in the M013-M015 assessments. Co-Authored-By: Claude Fable 5 --- .../JobApplicationsAuthorizationTests.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs b/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs index fb29a95..61ef49d 100644 --- a/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs @@ -38,6 +38,42 @@ public sealed class JobApplicationsAuthorizationTests Assert.IsType(result.Result); } + [Fact] + public async Task GetMatchScore_returns_not_found_for_other_users_job() + { + var dbName = Guid.NewGuid().ToString(); + await using var ownerDb = CreateDb(dbName, "owner-1"); + var company = new Company { Name = "Acme", OwnerUserId = "owner-1" }; + ownerDb.Companies.Add(company); + await ownerDb.SaveChangesAsync(); + ownerDb.JobApplications.Add(new JobApplication { JobTitle = "Secret", CompanyId = company.Id, OwnerUserId = "owner-1", Description = "C# .NET" }); + await ownerDb.SaveChangesAsync(); + var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync(); + + await using var attackerDb = CreateDb(dbName, "other-user"); + var result = await CreateController(attackerDb).GetMatchScore(jobId, CancellationToken.None); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetStatusSuggestion_returns_not_found_for_other_users_job() + { + var dbName = Guid.NewGuid().ToString(); + await using var ownerDb = CreateDb(dbName, "owner-1"); + var company = new Company { Name = "Acme", OwnerUserId = "owner-1" }; + ownerDb.Companies.Add(company); + await ownerDb.SaveChangesAsync(); + ownerDb.JobApplications.Add(new JobApplication { JobTitle = "Secret", CompanyId = company.Id, OwnerUserId = "owner-1" }); + await ownerDb.SaveChangesAsync(); + var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync(); + + await using var attackerDb = CreateDb(dbName, "other-user"); + var result = await CreateController(attackerDb).GetStatusSuggestion(jobId, CancellationToken.None); + + Assert.IsType(result.Result); + } + private static JobTrackerContext CreateDb(string dbName, string? userId) { var options = new DbContextOptionsBuilder() From 2996441f52474ed4e8f48ad36668c4f9dddb8cd5 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 03:56:06 +0200 Subject: [PATCH 21/27] perf: drop duplicated company-existence query in job Create The create path ran the same Companies.AnyAsync existence check twice. Co-Authored-By: Claude Fable 5 --- JobTrackerApi/Controllers/JobApplicationsController.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 4f0ba63..793df02 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1405,9 +1405,7 @@ Canonical profile: if (title.Length == 0) return BadRequest("Job title is required."); if (request.CompanyId <= 0) return BadRequest("Valid companyId is required."); - var companyOk = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken); - if (!companyOk) return BadRequest("companyId does not exist."); - + // Scoped by the Company query filter, so this also rejects another user's companyId. var companyExists = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken); if (!companyExists) return BadRequest("companyId does not exist."); From 5a9245cf74c746ee427f530705b3a5b7dc62789d Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 03:56:58 +0200 Subject: [PATCH 22/27] docs: add security review of session changes (Phase 6) Scoped security review of Wave 0 + H1-H4: confirms tenant isolation on new endpoints (query filters + tests), no injection/ReDoS, dev-only OpenAPI. Flags DataProtection key rotation as the operator action item. Co-Authored-By: Claude Fable 5 --- docs/SECURITY_REPORT.md | 122 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/SECURITY_REPORT.md diff --git a/docs/SECURITY_REPORT.md b/docs/SECURITY_REPORT.md new file mode 100644 index 0000000..f7c0287 --- /dev/null +++ b/docs/SECURITY_REPORT.md @@ -0,0 +1,122 @@ +# SECURITY_REPORT.md — Session Change Review + +> Phase 6 deliverable. Scope: security review of the changes made in this work session +> (Wave 0 + roadmap H1–H4), plus confirmation that the tenant-isolation model still holds. +> Date: 2026-07-03. Complements the prior standalone assessments in +> `docs/security-assessments/` (M013 adversarial, M014 remediation, M015 authorization replay). + +This is **not** a full re-audit of the whole application — those live in `docs/security-assessments/`. +It is a focused review of the new/changed surface so nothing shipped this session introduces a regression. + +--- + +## 1. Summary + +No new vulnerabilities were introduced. Tenant isolation on the new endpoints is carried by the +existing `JobTrackerContext` global query filters and is now covered by regression tests. One latent +correctness issue (a routable background-service method) was closed, and leaked runtime secrets were +removed from version control (rotation recommended — see §6). + +| Severity | Count | Items | +|---|---|---| +| Critical | 0 | — | +| High | 0 | — | +| Medium | 1 (mitigated) | DataProtection keys present in git history (untracked this session; rotation recommended) | +| Low / hardening | 3 | see §5 | + +--- + +## 2. New/changed attack surface reviewed + +| Change | Surface | Verdict | +|---|---|---| +| `GET /jobapplications/{id}/match-score` | route int id; reads own CV + job | Safe — tenant-scoped | +| `GET /jobapplications/{id}/status-suggestion` | route int id; reads own correspondence | Safe — tenant-scoped | +| `GET /jobapplications/pipeline` | none (static metadata) | Safe | +| `PATCH .../status`, Create/Update (status normalization) | user string → `JobPipeline.Normalize` | Safe — no injection, values stored parameterized | +| Structured salary fields | numeric + short strings, `NormalizeSalary` | Safe — clamps negatives, whitelists period | +| Automated DB backup (`VACUUM INTO`) | server-controlled path | Safe — see §4 | +| Dev OpenAPI (`/openapi/v1.json`) | schema | Safe — `Development` environment only | +| `EmailStatusClassifier` | reads stored correspondence text | Safe — deterministic, no eval/injection | + +--- + +## 3. OWASP-oriented checklist for the new code + +- **A01 Broken Access Control** — The two new data endpoints load the job via + `_db.JobApplications.FirstOrDefaultAsync(j => j.Id == id)`, which is filtered by the global + query filter `CurrentUserId != null && OwnerUserId == CurrentUserId` (deny-on-null, hardened in + M013-2). A cross-user id returns `NotFound`, not another tenant's data. The correspondence lookup + in `status-suggestion` and the `JobEvent` lookup in analytics are likewise filtered through their + parent's owner. **Verified by `JobApplicationsAuthorizationTests` (match-score + status-suggestion).** +- **A03 Injection** — All new persistence goes through EF Core parameterized queries. The only raw + SQL added is `VACUUM INTO ''` with a fully server-derived path (see §4). No string + concatenation of user input into queries. +- **A03 ReDoS** — New regexes (`JobCvMatchService.TokenPattern`, the revised `SkillTagger` C#/.NET + patterns with fixed-width look-behinds) are linear with no catastrophic backtracking. +- **A04 Insecure Design** — Status suggestions and match scoring are deterministic and + **human-confirmed** (a status only changes when the user clicks). No automated outbound actions. +- **A05 Security Misconfiguration** — OpenAPI is exposed only under `IsDevelopment()`; production + deployments (`ASPNETCORE_ENVIRONMENT=Production`) do not serve it. +- **A08 Data Integrity** — `JobPipeline.Normalize` canonicalizes status on write but preserves + unknown custom values (no silent data loss). +- **A09 Logging** — No secrets or PII added to logs by the new code. + +--- + +## 4. Database backup — path handling + +`SqliteDatabaseBackupRunner` runs `VACUUM INTO ''`. The target is +`/backups/jobtracker_backup_.db` — no user input reaches it — and single +quotes are escaped defensively. Backups contain the full database (sensitive) and are written to the +same data volume as the live DB, i.e. the same trust boundary; they are git-ignored. For defense in +depth, operators should ship backups off-host with transport encryption and restrict volume +permissions. **Recommendation (low):** document an off-host, encrypted backup rotation in the +deployment guide. + +--- + +## 5. Low / hardening findings + +1. **match-score input size (low).** `GetMatchScore` does not cap job-description length before + tokenizing. Descriptions are bounded in practice (imported/typed), and the algorithm is linear, so + this is not a DoS, but a defensive cap (e.g. 50 KB) would be prudent. +2. **New read endpoints are not rate-limited (low).** `match-score`/`status-suggestion` are cheap and + deterministic (no AI, one indexed query), and auth-gated in production, so abuse potential is low. + Consider a general authenticated-read limiter if the API is exposed publicly. +3. **status-suggestion is conservative for custom statuses (informational).** A job in a non-canonical + custom status (pipeline order = max) never receives a suggestion. This is safe (fails closed) but + slightly under-surfaces; acceptable given custom statuses are rare. + +--- + +## 6. Secrets hygiene (actioned this session) + +- Committed ASP.NET **DataProtection key XML files** (`keys/`, `JobTrackerApi/keys/`) and daily export + JSON were removed from tracking and added to `.gitignore` + (commit `security: untrack DataProtection keys and runtime exports…`). +- **These key files remain in git history.** DataProtection keys sign auth/session artifacts, so + **rotating them on the production host is recommended** (generate fresh keys; the app regenerates the + key ring in the persisted `keys/` directory on next start). Until rotated, anyone with history access + could read the old key material. +- Local `.env` remains git-ignored; `appsettings.Development.json` contains only `CHANGE_ME_*` + placeholders. No live secrets are tracked. + +--- + +## 7. Confirmed intact from prior assessments + +Spot-checked that the M013–M015 remediations are still in force after this session's changes: + +- Owner query filters still deny on null `CurrentUserId` (`Data/JobTrackerContext.cs`). +- Local JWT still requires a concrete subject claim (`LocalAuthIdentity`, `Program.cs`). +- Job-import SSRF guard (DNS resolution + private-range rejection, redirects disabled) untouched. +- CSRF double-submit middleware and CORS allowlist untouched. + +--- + +## 8. Retest + +All backend tests pass (135), including the two new tenant-isolation tests for the new endpoints. +No fix in this report required code changes beyond what already landed; the residual **action for the +operator is DataProtection key rotation** (§6). From fb11469a4874c1e3bf4eec659fac2ca908e10c6f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 04:12:36 +0200 Subject: [PATCH 23/27] feat: quick-capture bookmarklet One-click job capture from any posting, reusing the existing jobimport/preview parser. - AddJobModal accepts initialUrl and auto-imports once on open - App reads a /?add= param, opens Add Job pre-filled, and strips the param from the address bar - QuickCaptureCard in Settings offers a draggable bookmarklet (href set via ref since React blocks javascript: URLs) plus copyable code - EN/NB translations; README feature note - 2 frontend tests; full suite green (22 suites / 50 tests) Co-Authored-By: Claude Fable 5 --- README.md | 1 + job-tracker-ui/src/App.tsx | 14 +++- job-tracker-ui/src/components/AddJobModal.tsx | 27 +++++-- .../src/components/QuickCaptureCard.tsx | 69 ++++++++++++++++++ .../src/components/SettingsView.tsx | 3 + job-tracker-ui/src/i18n/translations.ts | 10 +++ job-tracker-ui/src/quick-capture.test.tsx | 70 +++++++++++++++++++ 7 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 job-tracker-ui/src/components/QuickCaptureCard.tsx create mode 100644 job-tracker-ui/src/quick-capture.test.tsx diff --git a/README.md b/README.md index 55cd4aa..23c5865 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re - History/event trail per application (created, status changes, follow-up set, delete/restore) - Export jobs to JSON/CSV + daily scheduled JSON export - Optional “job import” preview from supported job sites (plugins) + optional translation to English +- Quick-capture bookmarklet (Settings): opens `/?add=` to pre-fill Add Job from any posting - Optional local AI service for short/full descriptions - Optional Google sign-in (Google ID tokens) to protect the API diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 5f5cd27..3f4ccee 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -109,6 +109,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo const compactHeaderActions = useMediaQuery("(max-width:767.95px)"); const [addOpen, setAddOpen] = useState(false); + const [captureUrl, setCaptureUrl] = useState(undefined); const [quickOpen, setQuickOpen] = useState(false); const [refreshToken, setRefreshToken] = useState(0); const [requireAuth, setRequireAuth] = useState(null); @@ -124,6 +125,17 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo useEffect(() => { api.get("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false)); }, []); + + // Quick-capture bookmarklet target: /?add= opens Add Job pre-filled. + useEffect(() => { + const params = new URLSearchParams(location.search); + const add = params.get("add"); + if (!add) return; + setCaptureUrl(add); + setAddOpen(true); + params.delete("add"); + navigate({ pathname: location.pathname, search: params.toString() }, { replace: true }); + }, [location.search, location.pathname, navigate]); useEffect(() => { let active = true; api.get("/auth/me") @@ -288,7 +300,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo - setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} /> + { setAddOpen(false); setCaptureUrl(undefined); }} onCreated={() => { setRefreshToken((t) => t + 1); }} /> setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} /> diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index c5592bf..68584af 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; @@ -37,6 +37,7 @@ interface Props { open: boolean; onClose: () => void; onCreated: () => void; + initialUrl?: string; } type DuplicateCandidate = { @@ -97,7 +98,7 @@ function normalizeLanguage(value?: string | null) { return raw; } -export default function AddJobModal({ open, onClose, onCreated }: Props) { +export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Props) { const { toast } = useToast(); const { t, language } = useI18n(); @@ -137,6 +138,21 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { setCompanies(cachedCompanies); }, [cachedCompanies]); + // Quick-capture: when opened with a URL (from the bookmarklet), prefill and auto-import once. + const autoImportedUrlRef = useRef(null); + useEffect(() => { + if (!open) { + autoImportedUrlRef.current = null; + return; + } + const url = initialUrl?.trim(); + if (!url || autoImportedUrlRef.current === url) return; + autoImportedUrlRef.current = url; + setJobUrl(url); + void importFromUrl(url); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, initialUrl]); + const resetForm = () => { setCompany(null); setCompanyInput(""); @@ -223,16 +239,17 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { } }; - const importFromUrl = async () => { + const importFromUrl = async (urlArg?: string) => { if (importing) return; - if (!jobUrl.trim()) { + const url = (urlArg ?? jobUrl).trim(); + if (!url) { toast(t("addJobModalPasteUrlFirst"), "warning"); return; } setImporting(true); try { - const res = await api.post("/jobimport/preview", { url: jobUrl.trim() }); + const res = await api.post("/jobimport/preview", { url }); const r = res.data; if (!r?.success) throw new Error(r?.error || t("addJobModalImportFailed")); diff --git a/job-tracker-ui/src/components/QuickCaptureCard.tsx b/job-tracker-ui/src/components/QuickCaptureCard.tsx new file mode 100644 index 0000000..2b57109 --- /dev/null +++ b/job-tracker-ui/src/components/QuickCaptureCard.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useRef } from "react"; + +import { Box, Paper, TextField, Typography } from "@mui/material"; + +import { useI18n } from "../i18n/I18nProvider"; +import { useToast } from "../toast"; + +/** The bookmarklet opens the app at /?add=, which triggers quick-capture. */ +function buildBookmarklet(origin: string): string { + // Kept as a single minified expression; opens a small popup so the user's tab is undisturbed. + return `javascript:void(window.open('${origin}/?add='+encodeURIComponent(location.href),'jobbjakt','width=520,height=720'))`; +} + +export default function QuickCaptureCard() { + const { t } = useI18n(); + const { toast } = useToast(); + const linkRef = useRef(null); + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const bookmarklet = buildBookmarklet(origin); + + // React refuses to render javascript: hrefs, so set it directly on the DOM node. + useEffect(() => { + if (linkRef.current) linkRef.current.setAttribute("href", bookmarklet); + }, [bookmarklet]); + + return ( + + {t("settingsQuickCaptureTitle")} + {t("settingsQuickCaptureSubtitle")} + + + { + // Clicking (vs dragging) shouldn't navigate; the value is meant to be dragged to the bar. + e.preventDefault(); + toast(t("settingsQuickCaptureDragHint"), "info"); + }} + sx={{ + display: "inline-block", + px: 2, + py: 1, + borderRadius: 2, + border: "1px solid", + borderColor: "primary.main", + color: "primary.main", + fontWeight: 800, + textDecoration: "none", + cursor: "grab", + userSelect: "none", + }} + > + {t("settingsQuickCaptureButton")} + + {t("settingsQuickCaptureDragHint")} + + + e.target.select()} + /> + + ); +} diff --git a/job-tracker-ui/src/components/SettingsView.tsx b/job-tracker-ui/src/components/SettingsView.tsx index 30ee4d9..b77c628 100644 --- a/job-tracker-ui/src/components/SettingsView.tsx +++ b/job-tracker-ui/src/components/SettingsView.tsx @@ -24,6 +24,7 @@ import ImportExportJobs from "./ImportExportJobs"; import GoogleAuthCard from "./GoogleAuthCard"; import RulesSettingsCard from "./RulesSettingsCard"; import BackupCard from "./BackupCard"; +import QuickCaptureCard from "./QuickCaptureCard"; import AuthStatusCard from "./AuthStatusCard"; import { ThemeModePref } from "../themePrefs"; import { useI18n } from "../i18n/I18nProvider"; @@ -297,6 +298,8 @@ export default function SettingsView({ + + diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 6c72577..fae2022 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -157,6 +157,11 @@ export const translations = { settingsOpenReminderInbox: "Open reminders", settingsReviewJobs: "Review jobs", settingsNotificationsTitle: "Notification settings", + settingsQuickCaptureTitle: "Quick capture bookmarklet", + settingsQuickCaptureSubtitle: "Drag this button to your bookmarks bar. On any job posting, click it to open Add Job pre-filled from that page.", + settingsQuickCaptureButton: "+ Save to Jobbjakt", + settingsQuickCaptureDragHint: "Drag me to your bookmarks bar", + settingsQuickCaptureManual: "Or copy the bookmarklet code", settingsNotificationsBody: "Choose which reminders should show up in your workflow. SMTP delivery can be checked from the system page.", settingsNotificationsDelivery: "SMTP delivery and test mail live under Admin → System → Settings.", settingsNotificationsFollowUpReminders: "Email reminders for follow-ups", @@ -1096,6 +1101,11 @@ export const translations = { settingsOpenReminderInbox: "Åpne påminnelser", settingsReviewJobs: "Gå til jobber", settingsNotificationsTitle: "Varslingsinnstillinger", + settingsQuickCaptureTitle: "Hurtiglagring (bokmerke)", + settingsQuickCaptureButton: "+ Lagre til Jobbjakt", + settingsQuickCaptureSubtitle: "Dra denne knappen til bokmerkelinjen. På en stillingsannonse klikker du på den for å åpne Legg til jobb forhåndsutfylt fra siden.", + settingsQuickCaptureDragHint: "Dra meg til bokmerkelinjen", + settingsQuickCaptureManual: "Eller kopier bokmerkekoden", settingsNotificationsBody: "Velg hvilke påminnelser som skal vises i arbeidsflyten din. SMTP-levering kan kontrolleres fra systemsiden.", settingsNotificationsDelivery: "SMTP-levering og test-epost ligger under Admin → System → Innstillinger.", settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger", diff --git a/job-tracker-ui/src/quick-capture.test.tsx b/job-tracker-ui/src/quick-capture.test.tsx new file mode 100644 index 0000000..c996d13 --- /dev/null +++ b/job-tracker-ui/src/quick-capture.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import { ToastProvider } from './toast'; +import { I18nProvider } from './i18n/I18nProvider'; +import { api } from './api'; + +// Avoid pulling the date-fns v4 ESM adapter into Jest; the picker isn't under test here. +jest.mock('@mui/x-date-pickers/DatePicker', () => ({ + DatePicker: ({ label }: any) =>
{label}
, +})); + +// eslint-disable-next-line import/first +import AddJobModal from './components/AddJobModal'; + +jest.setTimeout(15000); + +jest.mock('./api', () => ({ + api: { + get: jest.fn(() => Promise.resolve({ data: [] })), + post: jest.fn(() => Promise.resolve({ data: {} })), + put: jest.fn(() => Promise.resolve({ data: {} })), + patch: jest.fn(() => Promise.resolve({ data: {} })), + delete: jest.fn(() => Promise.resolve({ data: {} })), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, + getApiErrorMessage: jest.fn(() => 'error'), +})); + +const mockedApi = api as jest.Mocked; + +function renderModal(initialUrl?: string) { + return render( + + + {}} onCreated={() => {}} /> + + , + ); +} + +beforeEach(() => { + mockedApi.get.mockResolvedValue({ data: [] } as any); + mockedApi.post.mockImplementation((url: string) => { + if (url === '/jobimport/preview') { + return Promise.resolve({ data: { success: true, title: 'Imported Backend Role', company: 'Acme', location: 'Oslo', description: 'desc', tags: ['C#'] } } as any); + } + return Promise.resolve({ data: {} } as any); + }); +}); + +afterEach(() => jest.clearAllMocks()); + +test('auto-imports from initialUrl and prefills the form', async () => { + renderModal('https://example.com/jobs/123'); + + await waitFor(() => { + expect(mockedApi.post).toHaveBeenCalledWith('/jobimport/preview', { url: 'https://example.com/jobs/123' }); + }); + + expect(await screen.findByDisplayValue('Imported Backend Role')).toBeInTheDocument(); +}); + +test('does not auto-import when no initialUrl is given', async () => { + renderModal(undefined); + + // Wait for the modal to render, then confirm no import was triggered. + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalledWith('/jobimport/preview', expect.anything()); +}); From 30bb6a942d2b6501116f5e3f4020b2a7af3944d5 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 04:16:44 +0200 Subject: [PATCH 24/27] feat: installable PWA with mobile share-target capture - Corrected manifest (Jobbjakt branding, matching green theme, maskable icons, description/categories/scope/id). - share_target (GET) maps a shared url/link into the same /?add= capture flow the bookmarklet uses, so mobile 'Share -> Jobbjakt' pre-fills Add Job. - resolveCaptureUrl helper (tested) extracts the link from add or from a link embedded in shared text; App uses it and strips the params. - Deliberately no offline service worker: the app deploys frequently and an aggressive cache would risk stale builds (documented in README). - 4 unit tests; build compiles. Co-Authored-By: Claude Fable 5 --- README.md | 3 +- job-tracker-ui/public/manifest.json | 61 +++++++++++++++++---------- job-tracker-ui/src/App.tsx | 13 +++--- job-tracker-ui/src/captureUrl.test.ts | 22 ++++++++++ job-tracker-ui/src/captureUrl.ts | 10 +++++ 5 files changed, 80 insertions(+), 29 deletions(-) create mode 100644 job-tracker-ui/src/captureUrl.test.ts create mode 100644 job-tracker-ui/src/captureUrl.ts diff --git a/README.md b/README.md index 23c5865..e7a4e7f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re - History/event trail per application (created, status changes, follow-up set, delete/restore) - Export jobs to JSON/CSV + daily scheduled JSON export - Optional “job import” preview from supported job sites (plugins) + optional translation to English -- Quick-capture bookmarklet (Settings): opens `/?add=` to pre-fill Add Job from any posting +- Quick-capture bookmarklet (Settings) + installable PWA with a mobile share-target: both open `/?add=` to pre-fill Add Job from any posting + - Note: no offline service-worker cache is bundled by design (the app is deployed frequently; an aggressive cache would risk serving stale builds). The manifest provides installability and share-to-capture without it. - Optional local AI service for short/full descriptions - Optional Google sign-in (Google ID tokens) to protect the API diff --git a/job-tracker-ui/public/manifest.json b/job-tracker-ui/public/manifest.json index aac13d1..c16891f 100644 --- a/job-tracker-ui/public/manifest.json +++ b/job-tracker-ui/public/manifest.json @@ -1,25 +1,40 @@ { - "short_name": "JobTrack", - "name": "JobTrack — Job Application Tracker", - "icons": [ - { - "src": "favicon.ico", - "sizes": "64x64 32x32 24x24 16x16", - "type": "image/x-icon" - }, - { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" - } - ], - "start_url": ".", - "display": "standalone", - "theme_color": "#0b1224", - "background_color": "#0b1224" + "short_name": "Jobbjakt", + "name": "Jobbjakt — Job Application Tracker", + "description": "Track and manage your job applications, tailor CVs, and stay on top of follow-ups.", + "id": "/", + "scope": "/", + "start_url": ".", + "display": "standalone", + "orientation": "portrait-primary", + "categories": ["productivity", "business"], + "theme_color": "#15803d", + "background_color": "#0b1224", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192", + "purpose": "any maskable" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "any maskable" + } + ], + "share_target": { + "action": "/", + "method": "GET", + "params": { + "url": "add", + "text": "addtext" + } + } } diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 3f4ccee..228c397 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -32,6 +32,7 @@ import ForgotPasswordPage from "./pages/ForgotPasswordPage"; import ResetPasswordPage from "./pages/ResetPasswordPage"; import RouteErrorPage from "./pages/RouteErrorPage"; import { api } from "./api"; +import { resolveCaptureUrl } from "./captureUrl"; import { clearAuthClientState, setAuthUserKey } from "./auth"; import AppShell, { NavItem } from "./layout/AppShell"; import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs"; @@ -126,14 +127,16 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo api.get("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false)); }, []); - // Quick-capture bookmarklet target: /?add= opens Add Job pre-filled. + // Quick-capture target: bookmarklet (/?add=) or PWA share (url in `add`, or a link + // embedded in shared `addtext`). Opens Add Job pre-filled and strips the params. useEffect(() => { - const params = new URLSearchParams(location.search); - const add = params.get("add"); - if (!add) return; - setCaptureUrl(add); + const url = resolveCaptureUrl(location.search); + if (!url) return; + setCaptureUrl(url); setAddOpen(true); + const params = new URLSearchParams(location.search); params.delete("add"); + params.delete("addtext"); navigate({ pathname: location.pathname, search: params.toString() }, { replace: true }); }, [location.search, location.pathname, navigate]); useEffect(() => { diff --git a/job-tracker-ui/src/captureUrl.test.ts b/job-tracker-ui/src/captureUrl.test.ts new file mode 100644 index 0000000..e4f198f --- /dev/null +++ b/job-tracker-ui/src/captureUrl.test.ts @@ -0,0 +1,22 @@ +import { resolveCaptureUrl } from './captureUrl'; + +describe('resolveCaptureUrl', () => { + test('reads the bookmarklet add param', () => { + expect(resolveCaptureUrl('?add=https%3A%2F%2Fexample.com%2Fjob')).toBe('https://example.com/job'); + }); + + test('extracts a url embedded in shared text', () => { + expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('Cool role here https://example.com/job/42 apply now'))) + .toBe('https://example.com/job/42'); + }); + + test('prefers add over addtext', () => { + expect(resolveCaptureUrl('?add=https%3A%2F%2Fa.com&addtext=' + encodeURIComponent('https://b.com'))) + .toBe('https://a.com'); + }); + + test('returns null when there is no url', () => { + expect(resolveCaptureUrl('')).toBeNull(); + expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('just some text, no link'))).toBeNull(); + }); +}); diff --git a/job-tracker-ui/src/captureUrl.ts b/job-tracker-ui/src/captureUrl.ts new file mode 100644 index 0000000..b171afc --- /dev/null +++ b/job-tracker-ui/src/captureUrl.ts @@ -0,0 +1,10 @@ +// Resolves the quick-capture URL from query params produced by the bookmarklet (`add`) +// or the PWA share-target (a link in `add`, or embedded in shared `addtext`). +export function resolveCaptureUrl(search: string): string | null { + const params = new URLSearchParams(search); + const add = params.get("add"); + if (add) return add; + const addText = params.get("addtext"); + if (addText) return addText.match(/https?:\/\/\S+/)?.[0] ?? null; + return null; +} From 3bd7b4b7e4e460aed8bda09386d6346cdbbdf586 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 04:29:27 +0200 Subject: [PATCH 25/27] docs: add merge-request summary for review Co-Authored-By: Claude Fable 5 --- docs/MERGE_REQUEST.md | 78 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/MERGE_REQUEST.md diff --git a/docs/MERGE_REQUEST.md b/docs/MERGE_REQUEST.md new file mode 100644 index 0000000..1240a76 --- /dev/null +++ b/docs/MERGE_REQUEST.md @@ -0,0 +1,78 @@ +# Merge Request — Wave 0 quick wins + Tier-1/Tier-2 features + +**Branch:** `chore/wave0-quick-wins` → `main` +**Scope:** 24 commits · 62 files · +3,165 / −489 +**Status:** all tests green (backend 135, frontend 23 suites / 54 tests), production build compiles. + +> Prepared for human review. Do **not** auto-merge. One operator action is required after merge +> (DataProtection key rotation — see *Known limitations*). + +--- + +## Summary + +Delivers the first two roadmap tiers plus the engineering-health groundwork, developed as small +conventional commits. Two design principles run through it: + +1. **Deterministic over "AI-guessy."** Match scoring, status suggestions, and pipeline logic are + pure/deterministic — instant, reproducible, and safe (the user confirms every state change). This + directly answers the market's most common complaint (hallucinated/generic AI output). +2. **One pathway, not two.** The bookmarklet and the PWA share-target feed a single `/?add=` capture + flow rather than parallel implementations. + +## What's included + +**Engineering health (Wave 0)** +- `security:` untracked committed DataProtection keys + daily exports; removed dead legacy controllers. +- `feat:` automated daily SQLite backups (`VACUUM INTO`, retention, startup catch-up) — prod previously + had **no** automated backup on Linux. +- `ci:` run the **entire** frontend suite (the old whitelist was hiding 3 broken suites, now fixed). +- `feat:` dev-only OpenAPI at `/openapi/v1.json`; `feat:` structured salary fields. + +**Tier-1 features** +- **Match score** (`GET /jobapplications/{id}/match-score`) — deterministic CV↔job keyword coverage + (0–100) + matched/missing keywords + section coverage. Instant panel on the Candidate Fit tab. +- **Canonical pipeline** — `JobPipeline` single source of truth; status normalized on write (custom + values preserved); UI deduped across 5 files; `GET .../pipeline`. +- **Analytics v2** — time-in-stage medians (from `StatusChanged` history) + funnel driven by the + pipeline (fixes a bug that omitted the Waiting stage). +- **Status suggestions** — deterministic email→status classifier surfaced as a human-confirmed banner. + +**Tier-2 features** +- **Bookmarklet** quick-capture (Settings) reusing `jobimport/preview`. +- **Installable PWA** with a mobile share-target into the same capture flow. + +**Quality** +- Phase-6 security review (`docs/SECURITY_REPORT.md`): tenant isolation on new endpoints verified + + regression-tested; no injection/ReDoS; dev-only OpenAPI. +- Bug fixes: `SkillTagger` C#/.NET regex (silently missed those skills everywhere), a React + stale-closure, a duplicated DB query, and 3 pre-existing hidden test failures. + +## Test coverage added + +New pure/unit-tested services: `JobCvMatchService` (7), `JobPipeline` (14), `StageAnalytics` (4), +`EmailStatusClassifier` (7). New endpoint integration + authorization tests (match-score, +status-suggestion). New frontend tests: match-score panel, status-suggestion banner, pipeline, +quick-capture, capture-url resolution. + +## Docs + +New: `docs/SYSTEM_OVERVIEW.md`, `docs/PRODUCT_RESEARCH.md`, `docs/ROADMAP.md`, +`docs/SECURITY_REPORT.md`. README updated with the new endpoints, backup/pipeline config, and +quick-capture/PWA notes. + +## Known limitations / follow-ups + +- **ACTION REQUIRED (security):** the removed DataProtection key XMLs remain in git **history**. + Rotate them on the production host after merge (see `SECURITY_REPORT.md` §6). +- **Per-user custom pipeline stages** were deliberately deferred (unproven demand; large surface). +- **No offline service worker** by design — the app deploys frequently and an aggressive cache would + risk serving stale builds. The PWA is installable and share-capable without it. +- Not yet done (future branches): interview hub (M3), contacts CRM (M4), god-controller decomposition, + performance pass, Vite migration. + +## Reviewer notes + +- Repo quirk: controllers/services compile via the `JobTrackerBackend` library, **not** the + `JobTrackerApi` host project (see `docs/SYSTEM_OVERVIEW.md` §2). +- All AI-adjacent features are deterministic and make no model calls. From d61dd6310b4ac289a978a1e5eabda328365af7bb Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 10:53:06 +0200 Subject: [PATCH 26/27] fix(build): give the frontend build 1GB /dev/shm CRA's build runs fork-ts-checker in a forked process whose IPC needs more than Docker's default 64MB /dev/shm; too little segfaults 'npm run build' (RpcIpcMessagePortClosedError / SIGSEGV) with no compile error. Set shm_size on the frontend image build so production deploys don't hit this. (CI runners need the same via their container options.) Co-Authored-By: Claude Fable 5 --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 9672408..aa48c78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,6 +54,9 @@ services: frontend: build: context: ./job-tracker-ui + # fork-ts-checker (CRA's build type-checker) needs more than Docker's default + # 64MB /dev/shm; too little causes a SIGSEGV during `npm run build`. + shm_size: '1gb' args: - REACT_APP_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID} # Optional override; default in production is `/api` From e1e508988a84f11e44f764e72ca848a8c22cd9e8 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 14:52:42 +0200 Subject: [PATCH 27/27] fix(deploy): make bundled Ollama opt-in to avoid duplicate container The compose file shipped its own ollama service, so 'docker compose pull' during deploy re-downloaded the Ollama image and a deploy that starts the AI stack would spin up a second Ollama alongside an existing/shared one. - ollama service moved behind a 'bundled-ollama' compose profile, so it is excluded from the default pull/up (no duplicate, no re-download) - ai-service no longer depends_on ollama and is documented to point at a shared instance via OLLAMA_BASE_URL (e.g. http://:11435) - deploy.sh no longer names ollama in 'compose up' To run a self-contained Ollama: docker compose --profile bundled-ollama up Co-Authored-By: Claude Opus 4.8 --- deploy/deploy.sh | 4 +++- docker-compose.yml | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/deploy/deploy.sh b/deploy/deploy.sh index da281d5..644595c 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -61,7 +61,9 @@ fi # Force recreation so updated port mappings, env vars, and container config always apply on deploy. compose up -d --force-recreate --remove-orphans backend frontend if [ "$DEPLOY_BUILD_AI_SERVICE" = "true" ]; then - compose up -d --force-recreate ai-service ollama + # Ollama is opt-in (compose "bundled-ollama" profile). Deploys reuse an + # existing/shared Ollama via OLLAMA_BASE_URL instead of starting a duplicate. + compose up -d --force-recreate ai-service fi if [ -n "${OLLAMA_MODEL:-}" ]; then diff --git a/docker-compose.yml b/docker-compose.yml index aa48c78..fb889e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,12 +75,14 @@ services: context: ./tools/summarizer dockerfile: Dockerfile environment: + # Point at an existing/shared Ollama by setting OLLAMA_BASE_URL in .env + # (e.g. http://:11435). The in-compose ollama service below is + # opt-in via the "bundled-ollama" profile, so it is NOT started by default + # and no duplicate Ollama container is created. - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434} - OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b} ports: - "8001:8001" - depends_on: - - ollama networks: - default - shared_services @@ -91,7 +93,11 @@ services: timeout: 10s retries: 3 + # Opt-in only: start with `docker compose --profile bundled-ollama up`. + # Left out of the default set so deploys reuse an existing/shared Ollama + # (configured via OLLAMA_BASE_URL) instead of spinning up a duplicate. ollama: + profiles: ["bundled-ollama"] image: ollama/ollama:latest ports: - "11434:11434"