52 Commits

Author SHA1 Message Date
cesnimda 811963749e Fix cross-user job history leak 2026-04-11 17:05:52 +02:00
cesnimda 41595605b9 Add hostile fixture setup for authz testing 2026-04-11 16:57:15 +02:00
cesnimda ac217dab53 Record security remediation verification 2026-04-11 16:31:05 +02:00
cesnimda 09e96ce381 Fail closed on malformed local auth 2026-04-11 16:29:53 +02:00
cesnimda 6a223a4b70 Harden job import SSRF validation 2026-04-11 16:26:14 +02:00
cesnimda b4719a9916 Add adversarial security assessment findings 2026-04-11 14:30:32 +02:00
cesnimda ce26325682 Tighten Gmail and export hot paths 2026-04-11 12:10:49 +02:00
cesnimda 33ac4b963b Optimize workspace and daily-loop surfaces 2026-04-11 12:03:49 +02:00
cesnimda 27fd70a2d7 refactor, security updates, cv extraction upgrades 2026-04-11 01:34:32 +02:00
cesnimda 806b200ac5 Trigger deploy for Gmail and CV rewrite fixes 2026-04-09 22:07:36 +02:00
cesnimda 269dcb3487 Handle disconnected Gmail and bound CV rewrite prompts 2026-04-09 22:07:36 +02:00
cesnimda dd10b635e6 Trigger deploy for MySQL rule settings bootstrap 2026-04-09 21:35:18 +02:00
cesnimda 8852b501f5 Create missing MySQL rule settings tables 2026-04-09 21:35:17 +02:00
cesnimda 2f4c6d5bb7 Trigger deploy for MySQL schema repair 2026-04-09 21:00:05 +02:00
cesnimda b6a36cd860 Repair MySQL auto increment drift for core tables 2026-04-09 21:00:04 +02:00
cesnimda 0fdfcd727d Trigger deploy after AI build split 2026-04-09 19:53:12 +02:00
cesnimda 6fb7b57b09 Stop rebuilding AI service on every deploy 2026-04-09 19:51:32 +02:00
cesnimda b8c91a22b6 Fix API startup by removing unused OpenAPI package 2026-04-04 16:43:26 +02:00
cesnimda 170f1390a9 Trigger deploy after relaxing AI gate 2026-04-02 14:53:38 +02:00
cesnimda a22ce08913 Do not block deploy on AI service health 2026-04-02 14:52:44 +02:00
cesnimda f7efad7337 Trigger rebuild after CI repro 2026-04-02 14:34:30 +02:00
cesnimda 947d4eeab9 Trigger redeploy after backend runtime fix 2026-04-02 14:06:52 +02:00
cesnimda f61da1869d Include JwtBearer in backend publish output 2026-04-02 14:06:48 +02:00
cesnimda 463d4277cd Trigger redeploy 2026-04-02 13:27:48 +02:00
cesnimda 7b9a97323e Fix backend Docker publish context 2026-04-02 12:49:49 +02:00
cesnimda 5cd34f17bb Complete Gmail correspondence workflow 2026-04-02 12:29:24 +02:00
cesnimda 1f34eb42d2 fix: include backend project in docker build context 2026-04-01 22:24:00 +02:00
cesnimda b87e673d38 feat: add gmail review actions 2026-04-01 21:54:05 +02:00
cesnimda 161ecb4b94 feat: add gmail review decisions 2026-04-01 21:45:01 +02:00
cesnimda a0e823facf test: opt gmail router tests into v7 future flags 2026-04-01 17:26:07 +02:00
cesnimda 5af2c66616 feat: add gmail review queue surface 2026-04-01 17:16:00 +02:00
cesnimda 69e78d8951 refactor: extract gmail matching service 2026-04-01 16:59:29 +02:00
cesnimda 61c12d3479 feat: add global correspondence inbox 2026-04-01 16:51:02 +02:00
cesnimda 3f04849fe6 feat: add correspondence inbox and gmail ingestion contract 2026-04-01 16:50:14 +02:00
cesnimda 289c2f47ad Merge feature branch feat/gmail-job-correspondence 2026-04-01 16:38:03 +02:00
cesnimda fd3527776a docs: update gmail workstream progress 2026-04-01 16:38:03 +02:00
cesnimda f48136f04c feat: enrich gmail correspondence metadata 2026-04-01 16:27:34 +02:00
cesnimda e5bcf9d5ea feat: harden gmail sync foundation 2026-04-01 16:09:29 +02:00
cesnimda 068ce447c0 Merge feature branch feat/cv-builder-parser-ollama 2026-04-01 15:54:00 +02:00
cesnimda 9191e4cc5b fix: harden admin system fallback and benchmark review 2026-04-01 13:38:22 +02:00
cesnimda cc55fc0cf8 chore: add summarizer bootstrap test script 2026-04-01 13:13:16 +02:00
cesnimda 0d65835857 feat: add cv benchmark workflow and admin visibility 2026-04-01 12:25:45 +02:00
cesnimda 0551a525a8 feat: add server-backed profile CV builder pipeline 2026-04-01 12:25:35 +02:00
cesnimda 22d7dd3573 chore: auto-commit after worktree-switch
GSD-Unit: CV_changes
2026-04-01 11:48:25 +02:00
cesnimda f22c6791a7 Improve CV rewrite flow and parser accuracy 2026-04-01 11:30:37 +02:00
cesnimda f402213526 Extend CV classifier contract and provenance UI 2026-04-01 11:06:55 +02:00
cesnimda b283f8b9d2 Improve classifier fallback for flat CV parsing 2026-04-01 11:00:53 +02:00
cesnimda 517c42250d Refactor repeated test host setup 2026-04-01 10:48:28 +02:00
cesnimda 18d1de45cb Refactor backend project and tighten CV test coverage 2026-04-01 10:42:55 +02:00
cesnimda 44000f96f2 Improve CV parsing and profile editor flow 2026-03-29 14:29:18 +02:00
cesnimda 99fc94bc18 Polish mobile layout and add collapsible sidebar 2026-03-29 14:24:43 +02:00
cesnimda 4253d33dfd Fix frontend build for CV profile and draft types 2026-03-29 00:58:05 +01:00
164 changed files with 16711 additions and 3305 deletions
+2
View File
@@ -3,12 +3,14 @@
# everything first and then opt back into only the source folders it needs.
*
!JobTrackerApi/
!JobTrackerBackend/
!Data/
!Models/
!.dockerignore
# Include the source trees.
!JobTrackerApi/**
!JobTrackerBackend/**
!Data/**
!Models/**
+3
View File
@@ -9,6 +9,9 @@ GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET
# Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback
GOOGLE_GMAIL_REDIRECT_URI=
AI_SERVICE_BASE_URL=http://ai-service:8001
# Optional: enables hybrid CV block classification in the local AI service.
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=qwen2.5:7b
# Optional: only needed if you want the UI to call a non-default API base URL.
# In production the UI defaults to `/api`.
+22 -24
View File
@@ -64,6 +64,7 @@ jobs:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
command_timeout: 40m
script: |
set -euo pipefail
if [ ! -d /opt/job-tracker/app/.git ]; then
@@ -89,30 +90,27 @@ jobs:
docker compose ps
AI_CONTAINER_ID="$(docker compose ps -q ai-service)"
if [ -z "$AI_CONTAINER_ID" ]; then
echo "AI service container id could not be resolved after deploy."
docker compose ps
docker compose logs --tail=200 ai-service || true
exit 1
fi
ATTEMPTS=90
SLEEP_SECS=2
i=1
while [ "$i" -le "$ATTEMPTS" ]; do
HEALTH_STATUS="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$AI_CONTAINER_ID" 2>/dev/null || echo unknown)"
if [ "$HEALTH_STATUS" = "healthy" ]; then
break
fi
if [ "$HEALTH_STATUS" = "unhealthy" ]; then
echo "AI service became unhealthy during deploy readiness wait."
echo "AI service container id could not be resolved after deploy. Continuing because AI is not a deploy gate for the core app."
else
ATTEMPTS=90
SLEEP_SECS=2
i=1
while [ "$i" -le "$ATTEMPTS" ]; do
HEALTH_STATUS="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$AI_CONTAINER_ID" 2>/dev/null || echo unknown)"
if [ "$HEALTH_STATUS" = "healthy" ]; then
break
fi
if [ "$HEALTH_STATUS" = "unhealthy" ]; then
echo "AI service became unhealthy during deploy readiness wait. Continuing because AI is not a deploy gate for the core app."
docker compose logs --tail=200 ai-service || true
break
fi
sleep "$SLEEP_SECS"
i=$((i + 1))
done
if [ "${HEALTH_STATUS:-unknown}" != "healthy" ]; then
echo "AI service did not become healthy within $((ATTEMPTS * SLEEP_SECS)) seconds. Final status: ${HEALTH_STATUS:-unknown}. Continuing because AI is not a deploy gate for the core app."
docker compose ps
docker compose logs --tail=200 ai-service || true
exit 1
fi
sleep "$SLEEP_SECS"
i=$((i + 1))
done
if [ "$HEALTH_STATUS" != "healthy" ]; then
echo "AI service did not become healthy within $((ATTEMPTS * SLEEP_SECS)) seconds. Final status: ${HEALTH_STATUS:-unknown}"
docker compose ps
docker compose logs --tail=200 ai-service || true
exit 1
fi
+3
View File
@@ -75,3 +75,6 @@ target/
.gsd/reports/
.gsd/milestones/**/continue.md
.gsd/milestones/**/*-CONTINUE.md
# ── GSD baseline (auto-generated) ──
.gsd-id
+3
View File
@@ -24,3 +24,6 @@
| D016 | M001/S07 | uat-artifact | How S07 daily-loop closure should capture acceptance evidence | Keep docs/s06-acceptance-run.md as the canonical execution log and use S07 closure artifacts to summarize/import the cross-surface proof rather than duplicating raw runner output. | S07's job is to prove one seeded job stays coherent across /jobs, workspace, /reminders, and /dashboard while preserving the manual-send boundary. Reusing the S06 runner output as the canonical source keeps reruns idempotent, prevents drift between generated logs and human summary text, and gives downstream slices one stable place for detailed evidence plus one concise dependency summary. | Yes | agent |
| D017 | M005 planning | delivery | How M005 execution should be staged and published | Execute M005 one slice at a time, verify each slice independently, push each slice on its own git branch, then continue to the next slice only after the prior slice is stable. | The CV intelligence/export milestone is high-risk and multi-layered. Slice-by-slice branching and push discipline will keep extraction, tailored draft, and PDF rendering changes reviewable and reduce regression blast radius. | Yes | human |
| D018 | M005 planning | verification | What document corpus should drive universal CV extraction verification | Use the real CV files placed in /home/pi/cvs as a regression corpus for universal extractor work, alongside synthetic/unit fixtures. | A universal CV extractor cannot be validated only against synthetic fixtures. Real CVs with different layouts, OCR quality, and structure are required to test extraction, review UX, and rendering assumptions. | Yes | human |
| D019 | M011/S01 | frontend-platform | How to handle frontend build-tool risk during the initial platform hardening slice | Remediate the direct critical frontend dependency immediately, keep the CRA baseline for the next hardening slice, and defer the broader frontend build-tool migration to a later dedicated implementation step. | The audit showed one critical direct dependency issue (`axios`) and a large remaining body of transitive risk concentrated behind `react-scripts`. Upgrading the direct dependency removed the critical finding with low change surface, restored a reproducible local and Docker build baseline, and avoids coupling S02 auth/session work to a framework migration. The remaining CRA transitive debt is still real, but it is now a contained follow-on migration concern rather than an immediate blocker. | Yes | agent |
| D020 | M011/S02 | authentication | What session transport should replace browser-stored bearer tokens in the frontend and API | Use an HttpOnly cookie-backed app session for the primary local auth path, have the API read the local app JWT from a secure cookie instead of browser storage, keep Google credential exchange server-side, and add CSRF protection for state-changing requests. | The current design stores the app bearer token in localStorage/sessionStorage and attaches it via an Authorization header on every request, which leaves the primary local auth path exposed to XSS-driven token theft. A cookie-backed session keeps the app token out of browser storage, lets the API enforce the local auth path centrally, preserves existing JWT-based authorization semantics on the server, and gives the frontend a cleaner source of truth through `/auth/me` and explicit unauthorized responses. Adding CSRF protection alongside the cookie keeps state-changing requests safe under the new transport. | Yes | agent |
| D021 | M011/S03/T01 | frontend-architecture | How to centralize degraded-state handling for the core frontend views in S03. | Use a lightweight shared frontend async-view-state pattern for S03 instead of introducing a new global data-fetching framework in this slice. | The current risk is not lack of a full query library; it is that core views swallow request failures into empty arrays or nulls and then render normal empty states. A small shared abstraction for loading/empty/error/retry state can retire that product risk quickly across the highest-traffic views without broadening S03 into a framework migration or destabilizing the existing app. | Yes | agent |
+8
View File
@@ -10,3 +10,11 @@ User-issued overrides that supersede plan document content.
**Applied-at:** M001/S01/T01
---
## Override: 2026-04-10T16:46:22.130Z
**Change:** use next.js
**Scope:** active
**Applied-at:** M001/none/none
---
+24 -2
View File
@@ -26,6 +26,26 @@ This file is the explicit capability and coverage contract for the project.
- Validation: mapped
- Notes: Shared/team workflows are not the current product target.
### R018 — Run an adversarial security assessment against the application across input validation, authentication, authorization, API exposure, file uploads, and data exposure.
- Class: operational
- Status: active
- Description: Run an adversarial security assessment against the application across input validation, authentication, authorization, API exposure, file uploads, and data exposure.
- Why it matters: The next milestone is explicitly a hostile security-testing pass intended to find vulnerabilities before attackers do.
- Source: user-security-milestone
- Primary owning slice: M013
- Validation: Produce verified findings or an explicit no-finding result for each requested attack category.
- Notes: Assessment should assume weak protections and behave like an aggressive tester, not a happy-path reviewer.
### R019 — For each security issue found, record the vulnerability description, an example exploit input, risk level, and a clear remediation recommendation.
- Class: functional
- Status: active
- Description: For each security issue found, record the vulnerability description, an example exploit input, risk level, and a clear remediation recommendation.
- Why it matters: Security testing is only useful if the output is actionable for remediation and triage.
- Source: user-security-milestone
- Primary owning slice: M013
- Validation: Each finding includes description, exploit example, risk rating, and fix guidance.
- Notes: If no issue is found in a category, the milestone should still document what was tested and the observed boundary.
## Validated
### R001 — The user finds a job outside the app, imports it into the app, and starts the application workflow from that imported role.
@@ -218,10 +238,12 @@ This file is the explicit capability and coverage contract for the project.
| R015 | anti-feature | out-of-scope | none | none | n/a |
| R016 | out-of-scope | out-of-scope | none | none | n/a |
| R017 | out-of-scope | out-of-scope | none | none | n/a |
| R018 | operational | active | M013 | none | Produce verified findings or an explicit no-finding result for each requested attack category. |
| R019 | functional | active | M013 | none | Each finding includes description, exploit example, risk rating, and fix guidance. |
## Coverage Summary
- Active requirements: 2
- Mapped to slices: 2
- Active requirements: 4
- Mapped to slices: 4
- Validated: 8 (R001, R002, R003, R004, R005, R006, R007, R010)
- Unmapped active requirements: 0
+49
View File
@@ -7,3 +7,52 @@
{"cmd":"complete-task","params":{"milestoneId":"M001","sliceId":"S07","taskId":"T01"},"ts":"2026-03-27T08:36:36.314Z","actor":"agent","hash":"0aa4019d4a27538a","session_id":"96f47087-e006-4aa2-8147-1cc42da4374d"}
{"cmd":"complete-task","params":{"milestoneId":"M001","sliceId":"S07","taskId":"T02"},"ts":"2026-03-27T08:51:21.876Z","actor":"agent","hash":"7f6dfb093ecf298e","session_id":"96f47087-e006-4aa2-8147-1cc42da4374d"}
{"cmd":"complete-task","params":{"milestoneId":"M001","sliceId":"S07","taskId":"T03"},"ts":"2026-03-27T08:55:15.935Z","actor":"agent","hash":"0b8928a7f97d0d42","session_id":"96f47087-e006-4aa2-8147-1cc42da4374d"}
{"cmd":"plan-milestone","params":{"milestoneId":"M005"},"ts":"2026-03-28T22:04:42.705Z","actor":"agent","hash":"9f92dc9597f6bcca","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
{"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S01"},"ts":"2026-03-28T22:05:00.001Z","actor":"agent","hash":"94d3ace67d51aaad","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
{"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S02"},"ts":"2026-03-28T22:05:16.424Z","actor":"agent","hash":"cc2907fae86cc252","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
{"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S03"},"ts":"2026-03-28T22:05:32.786Z","actor":"agent","hash":"ae2f80720d601a48","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
{"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S04"},"ts":"2026-03-28T22:05:48.342Z","actor":"agent","hash":"38e10b5bfc9e49e6","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
{"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S05"},"ts":"2026-03-28T22:06:02.267Z","actor":"agent","hash":"a4cdfef1b0f97af3","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
{"cmd":"plan-milestone","params":{"milestoneId":"M006"},"ts":"2026-04-01T13:42:13.507Z","actor":"agent","hash":"4e6e2177aea2c247","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"}
{"cmd":"plan-milestone","params":{"milestoneId":"M007"},"ts":"2026-04-01T13:45:43.599Z","actor":"agent","hash":"f74c11f87b160d5e","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"}
{"cmd":"plan-milestone","params":{"milestoneId":"M010"},"ts":"2026-04-01T13:45:43.608Z","actor":"agent","hash":"0767a15a4163e364","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"}
{"cmd":"plan-milestone","params":{"milestoneId":"M009"},"ts":"2026-04-01T13:45:43.609Z","actor":"agent","hash":"868651dc3e9840ba","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"}
{"cmd":"plan-milestone","params":{"milestoneId":"M008"},"ts":"2026-04-01T13:45:43.611Z","actor":"agent","hash":"a17e013ae4c6fbc7","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"}
{"cmd":"plan-slice","params":{"milestoneId":"M006","sliceId":"S01"},"ts":"2026-04-01T13:46:55.228Z","actor":"agent","hash":"53e13651ee21608e","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"}
{"v":2,"cmd":"plan-milestone","params":{"milestoneId":"M011"},"ts":"2026-04-10T16:33:49.574Z","actor":"agent","hash":"8da5bd1f6d8be219","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S01"},"ts":"2026-04-10T16:36:01.325Z","actor":"agent","hash":"1b39eb81745f79cb","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S01","taskId":"T01"},"ts":"2026-04-10T16:45:13.023Z","actor":"agent","hash":"df43e89bf0ef508a","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S01","taskId":"T02"},"ts":"2026-04-10T16:46:52.982Z","actor":"agent","hash":"fc183a287cf7e0ec","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S01","taskId":"T03"},"ts":"2026-04-10T16:47:07.060Z","actor":"agent","hash":"96dbf0b722260441","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S01"},"ts":"2026-04-10T16:47:38.406Z","actor":"agent","hash":"e8b7e8fcc07292af","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S01"},"ts":"2026-04-10T16:47:48.162Z","actor":"agent","hash":"e8d28553a74cd045","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S02"},"ts":"2026-04-10T16:48:16.316Z","actor":"agent","hash":"c6f7c425cd77c100","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S02","taskId":"T01"},"ts":"2026-04-10T16:49:40.607Z","actor":"agent","hash":"1e247d4737f232b4","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S02","taskId":"T02"},"ts":"2026-04-10T19:57:16.264Z","actor":"agent","hash":"02eb6bc1686244e9","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S02","taskId":"T03"},"ts":"2026-04-10T19:57:41.031Z","actor":"agent","hash":"85c32d040f9631aa","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S02"},"ts":"2026-04-10T19:58:17.389Z","actor":"agent","hash":"3115b597816bc8cb","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S02"},"ts":"2026-04-10T19:58:21.945Z","actor":"agent","hash":"51ed90ab022e6ae9","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S03"},"ts":"2026-04-10T22:04:32.223Z","actor":"agent","hash":"10a79a238ead7007","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S03","taskId":"T01"},"ts":"2026-04-10T22:05:25.953Z","actor":"agent","hash":"4d7f978e674fb278","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S03","taskId":"T02"},"ts":"2026-04-10T22:19:14.274Z","actor":"agent","hash":"94e0f7a9b24dd246","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S03","taskId":"T03"},"ts":"2026-04-10T22:19:33.234Z","actor":"agent","hash":"31c5bb74a3280df0","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S03"},"ts":"2026-04-10T22:20:05.975Z","actor":"agent","hash":"7a76f48b67c6a4fa","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S03"},"ts":"2026-04-10T22:20:18.782Z","actor":"agent","hash":"0bdf677f91c94f7b","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S04"},"ts":"2026-04-10T22:32:19.950Z","actor":"agent","hash":"ad5a195d23e3979e","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S04","taskId":"T01"},"ts":"2026-04-10T22:33:06.567Z","actor":"agent","hash":"dff04d446600fb9c","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S04","taskId":"T02"},"ts":"2026-04-10T22:44:02.977Z","actor":"agent","hash":"0a94bd5f4e0d3c90","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S04","taskId":"T03"},"ts":"2026-04-10T22:44:23.671Z","actor":"agent","hash":"6148706a46d32f7b","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S04"},"ts":"2026-04-10T22:44:54.430Z","actor":"agent","hash":"e68c20060f20dd34","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S04"},"ts":"2026-04-10T22:45:07.836Z","actor":"agent","hash":"f92571f10029d5e9","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S05"},"ts":"2026-04-10T22:55:56.643Z","actor":"agent","hash":"39e7d7ed3cc34612","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S05","taskId":"T01"},"ts":"2026-04-10T22:56:09.946Z","actor":"agent","hash":"c133fd6bf6b26629","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S05","taskId":"T02"},"ts":"2026-04-10T22:59:48.954Z","actor":"agent","hash":"f603df2d0e5cd772","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S05","taskId":"T03"},"ts":"2026-04-10T23:00:30.352Z","actor":"agent","hash":"96ecf88ce819d73b","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S05"},"ts":"2026-04-10T23:00:57.810Z","actor":"agent","hash":"31a2aca44265f192","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S05"},"ts":"2026-04-10T23:01:02.519Z","actor":"agent","hash":"fe0bd7ec6ab8df21","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S06"},"ts":"2026-04-10T23:01:49.394Z","actor":"agent","hash":"f2b438884ca52230","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S06","taskId":"T01"},"ts":"2026-04-10T23:20:01.968Z","actor":"agent","hash":"406e0f3c172d1161","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S06","taskId":"T02"},"ts":"2026-04-10T23:24:03.823Z","actor":"agent","hash":"1a2544dcd9f4f925","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S06","taskId":"T03"},"ts":"2026-04-10T23:24:23.101Z","actor":"agent","hash":"f583516649531d4c","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S06"},"ts":"2026-04-10T23:24:52.479Z","actor":"agent","hash":"b2c2dc564fb09dfe","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-milestone","params":{"milestoneId":"M011"},"ts":"2026-04-10T23:25:36.547Z","actor":"agent","hash":"10b42cbd47fe0d4a","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
+9 -9
View File
@@ -1,15 +1,15 @@
# M001: M001: Gmail and draft quality loop
# M001: M001: M001: Gmail and draft quality loop
## Vision
Turn the existing job tracker into a daily-use personal job-search workspace where Gmail import and AI drafting are strong enough to trust, while preserving manual control over all real-world sending and applying.
M001: M001: Gmail and draft quality loop
## Slice Overview
| ID | Slice | Risk | Depends | Done | After this |
|----|-------|------|---------|------|------------|
| S01 | Smarter Gmail import and matching | high | — | ✅ | User can connect Gmail, review likely messages or threads for a job, import a message or full thread, and trust linked Gmail threads to stay current on that job without manual re-import. |
| S02 | Stronger AI application package drafting | high | S01 | ✅ | From an imported job plus profile/CV context, the app generates materially better tailored CV and cover-letter drafts that feel specific and usable. |
| S03 | Reply and follow-up drafting from real thread context | medium | S01, S02 | ✅ | Inside a job, the user can generate follow-up and reply drafts grounded in imported and automatically refreshed correspondence plus saved application context, then edit them before sending manually. |
| S04 | Daily control loop surfaces | medium | S01, S03 | ✅ | The job table works as the primary overview and the follow-up/dashboard surfaces clearly show what needs attention next for an individual user. |
| S05 | End-to-end trust and workflow polish | low | S01, S02, S03, S04 | ✅ | The full loop works cleanly in a real environment: import job → generate package → apply externally → import/update correspondence automatically from linked Gmail threads → draft follow-up/reply → track progress confidently. |
| S06 | Live environment stabilization and integrated acceptance rerun | high | S05 | ✅ | The real M001 environment runs without the current backend/frontend CORS/runtime blockage, and the full `/jobs` → workspace → Gmail continuity → follow-up → dashboard/reminders loop is re-checked live with recorded acceptance results. |
| S07 | Daily-loop UAT artifact closure | medium | S06 | ✅ | The overview-surface/browser validation is captured as a real executed UAT artifact instead of a placeholder, proving the same job behaves coherently across table, dashboard, reminders, and workspace entry. |
| S01 | Smarter Gmail import and matching | high | — | ✅ | TBD |
| S02 | Stronger AI application package drafting | high | S01 | ✅ | TBD |
| S03 | Reply and follow-up drafting from real thread context | medium | S01, S02 | ✅ | TBD |
| S04 | Daily control loop surfaces | medium | S01, S03 | ✅ | TBD |
| S05 | End-to-end trust and workflow polish | low | S01, S02, S03, S04 | ✅ | TBD |
| S06 | Live environment stabilization and integrated acceptance rerun | high | S05 | ✅ | TBD |
| S07 | Daily-loop UAT artifact closure | medium | S06 | ✅ | TBD |
+1 -1
View File
@@ -1,7 +1,7 @@
# S01: Smarter Gmail import and matching
**Goal:** Finish S01 by turning the existing job-aware Gmail import flow into a live linked-thread continuity loop for one job workspace.
**Demo:** After this: User can connect Gmail, review likely messages or threads for a job, import a message or full thread, and trust linked Gmail threads to stay current on that job without manual re-import.
**Demo:** After this: TBD
## Tasks
- [x] **T01: Add linked Gmail thread refresh to the backend contract**
@@ -12,7 +12,7 @@ drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: ""
completed_at: 2026-03-27T07:30:18.612Z
completed_at: 2026-03-28T22:02:57.777Z
blocker_discovered: false
---
@@ -12,7 +12,7 @@ drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: ""
completed_at: 2026-03-27T07:30:18.612Z
completed_at: 2026-03-28T22:02:57.777Z
blocker_discovered: false
---
+1 -1
View File
@@ -1,7 +1,7 @@
# S02: Stronger AI application package drafting
**Goal:** Make the application package generator use imported job/correspondence context well enough that tailored CV, cover-letter, and recruiter-message drafts feel specific, credible, and worth starting from inside the job workspace.
**Demo:** After this: From an imported job plus profile/CV context, the app generates materially better tailored CV and cover-letter drafts that feel specific and usable.
**Demo:** After this: TBD
## Tasks
- [x] **T01: Strengthen application-package context assembly and backend draft tests**
@@ -12,7 +12,7 @@ drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: ""
completed_at: 2026-03-27T07:30:18.613Z
completed_at: 2026-03-28T22:02:57.777Z
blocker_discovered: false
---
@@ -12,7 +12,7 @@ drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: ""
completed_at: 2026-03-27T07:30:18.613Z
completed_at: 2026-03-28T22:02:57.777Z
blocker_discovered: false
---
+1 -1
View File
@@ -1,7 +1,7 @@
# S03: Reply and follow-up drafting from real thread context
**Goal:** Make follow-up drafting use imported correspondence and saved application material well enough that the job workspace can produce specific, trustworthy follow-up and reply drafts without crossing the manual-send boundary.
**Demo:** After this: Inside a job, the user can generate follow-up and reply drafts grounded in imported and automatically refreshed correspondence plus saved application context, then edit them before sending manually.
**Demo:** After this: TBD
## Tasks
- [x] **T01: Strengthen follow-up draft context assembly and backend reply/follow-up tests**
@@ -12,7 +12,7 @@ drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: ""
completed_at: 2026-03-27T07:30:18.613Z
completed_at: 2026-03-28T22:02:57.777Z
blocker_discovered: false
---
@@ -12,7 +12,7 @@ drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: ""
completed_at: 2026-03-27T07:30:18.614Z
completed_at: 2026-03-28T22:02:57.777Z
blocker_discovered: false
---
+1 -1
View File
@@ -1,7 +1,7 @@
# S04: Daily control loop surfaces
**Goal:** Make the job table, reminders view, and dashboard behave like one daily control loop so the user can scan what needs attention and jump directly into the right job workspace state.
**Demo:** After this: The job table works as the primary overview and the follow-up/dashboard surfaces clearly show what needs attention next for an individual user.
**Demo:** After this: TBD
## Tasks
- [x] **T01: Turn reminders and dashboard into actionable entry surfaces**
@@ -12,7 +12,7 @@ drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: ""
completed_at: 2026-03-27T07:30:18.615Z
completed_at: 2026-03-28T22:02:57.778Z
blocker_discovered: false
---
@@ -12,7 +12,7 @@ drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: ""
completed_at: 2026-03-27T07:30:18.615Z
completed_at: 2026-03-28T22:02:57.778Z
blocker_discovered: false
---
+1 -1
View File
@@ -1,7 +1,7 @@
# S05: End-to-end trust and workflow polish
**Goal:** Prove the full daily-use loop as one trustworthy workflow by tightening shared next-action/readiness signals, then validating overview → workspace → package → Gmail continuity → follow-up behavior without weakening the manual-send boundary.
**Demo:** After this: The full loop works cleanly in a real environment: import job → generate package → apply externally → import/update correspondence automatically from linked Gmail threads → draft follow-up/reply → track progress confidently.
**Demo:** After this: TBD
## Tasks
- [x] **T01: Centralize workflow trust signals across overview and readiness surfaces**
@@ -12,7 +12,7 @@ drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: ""
completed_at: 2026-03-27T07:30:18.616Z
completed_at: 2026-03-28T22:02:57.778Z
blocker_discovered: false
---
@@ -12,7 +12,7 @@ drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: ""
completed_at: 2026-03-27T07:30:18.617Z
completed_at: 2026-03-28T22:02:57.778Z
blocker_discovered: false
---
+4 -40
View File
@@ -1,51 +1,15 @@
# S06: Live environment stabilization and integrated acceptance rerun
**Goal:** Live environment is repeatably startable and preflighted, seeded with acceptance-ready data, and the integrated daily loop is re-verified with a recorded artifact proving the manual-send boundary and individual-first workflow.
**Demo:** After this: The real M001 environment runs without the current backend/frontend CORS/runtime blockage, and the full `/jobs` → workspace → Gmail continuity → follow-up → dashboard/reminders loop is re-checked live with recorded acceptance results.
**Demo:** After this: TBD
## Tasks
- [x] **T01: Validated and recorded the live API/auth preflight gate, including README runbook guidance and negative-path shell coverage.**Build a repeatable preflight script and doc so environment blockers are caught before browser UAT.
- Why: avoid the ERR_CONNECTION_REFUSED/CORS/auth mismatch that currently blocks the UI.
- Steps:
1) Create `scripts/s06-preflight.sh` (bash, executable) that assumes backend already started; probes `/api/auth/config` and `/api/admin/system` on `http://localhost:5202/api`, printing database/auth/gmailConfigured/ai status and failing fast on unreachable endpoints.
2) Ensure script respects `API_BASE` env override and uses `curl -f` with readable errors; no secrets logged.
3) Add a short runbook snippet to `README.md` showing backend start command from `JobTrackerApi/` and how to run the preflight (including auth token note if required).
4) Sanity-check CORS expectations vs `job-tracker-ui/src/api.ts` and document the required origin pairing (UI :3000, API :5202).
- Failure Modes (Q5): API down → exit 1 with hint to start API; Auth required without token → script notes auth required and how to obtain; malformed JSON → show raw body and fail.
- Load Profile (Q6): trivial single-user curl calls; no scaling concern.
- Negative Tests (Q7): run script with API stopped (expect non-zero); run with wrong `API_BASE` (expect clear error message).
- Must-haves: preflight script exists/executable; README runbook mentions backend start + preflight; script outputs gmailConfigured/auth/db/ai fields.
- Verification: `bash scripts/s06-preflight.sh`
- Estimate: 45m
- [x] **T01: Validated and recorded the live API/auth preflight gate, including README runbook guidance and negative-path shell coverage.**
- Files: scripts/s06-preflight.sh, README.md, job-tracker-ui/src/api.ts, JobTrackerApi/appsettings.Development.json
- Verify: bash scripts/s06-preflight.sh
- [x] **T02: Seeded acceptance-ready job data through the live API with deterministic rerun-safe ids and readiness output.**Create a seed script that prepares a richer acceptance fixture (job, correspondence, saved package, follow-up readiness) using live API calls.
- Why: current DB has only 1 low-signal job; acceptance rerun needs actionable overview + workspace state.
- Steps:
1) Write `scripts/s06-acceptance-data.sh` (bash, executable) that requires `AUTH_TOKEN` env; uses `scripts/s06-preflight.sh` first, then POSTs to `/api/jobapplications` (or PUT existing ID) to create a job with saved package fields, correspondence entry, reminder/follow-up signals, and notes.
2) Add curl helpers for adding correspondence (`/api/correspondence/{jobId}` or equivalent), saving package material, and setting workflow/readiness if needed; use deterministic titles so rerun is idempotent (update if exists).
3) Emit a short summary of created/updated IDs so the acceptance run can target them; avoid logging token.
4) Document any manual token retrieval step in script comments.
- Failure Modes (Q5): missing AUTH_TOKEN → fail with guidance; 401/403 → explain token issue; 5xx → print response and fail; malformed response → show body and fail.
- Load Profile (Q6): few API calls; minimal DB impact.
- Negative Tests (Q7): run without AUTH_TOKEN (expect failure); rerun twice (should succeed idempotently); simulate 401 by bad token (expect clear message).
- Must-haves: script seeds at least one job with saved package + correspondence + follow-up readiness; outputs job id for UAT; uses preflight.
- Verification: `bash scripts/s06-acceptance-data.sh`
- Estimate: 1h
- [x] **T02: Seeded acceptance-ready job data through the live API with deterministic rerun-safe ids and readiness output.**
- Files: scripts/s06-acceptance-data.sh, scripts/s06-preflight.sh, README.md
- Verify: bash scripts/s06-acceptance-data.sh
- [x] **T03: Added a repeatable live acceptance runner and recorded real S06 browser evidence for the manual-send boundary and daily loop.**Execute the live acceptance loop and record results as an artifact for S07/UAT handoff.
- Why: prove the `/jobs → workspace → reminders/dashboard → follow-up/manual-send boundary` loop runs in the live stack after stabilization and seeding.
- Steps:
1) Create `scripts/s06-acceptance-run.sh` to orchestrate: ensure backend running, run preflight + seed scripts, then run existing automated regressions most relevant to the loop (e.g., `end-to-end-trust-loop.test.tsx`) and capture outputs.
2) Perform a guided browser run (can use agent-browser/Playwright) hitting /jobs, /reminders, /dashboard, opening the seeded job workspace, inspecting Tailored CV, Correspondence (linked-thread status), Follow-up draft manual-send boundary; note Gmail continuity if blocked.
3) Write findings and screenshots/links into `docs/s06-acceptance-run.md` (what passed, what blocked, manual-send boundary observation, Gmail continuity status). Call out any gaps explicitly.
4) Ensure commands avoid leaking tokens; artifacts redact secrets.
- Failure Modes (Q5): backend not running → script stops after preflight; tests fail → record failure in artifact; browser step blocked by auth → document and include auth instructions.
- Load Profile (Q6): single-user flows; test runner CPU-bound but acceptable.
- Negative Tests (Q7): note expected failure if Gmail remains unconfigured; ensure manual-send boundary not auto-triggered during run.
- Must-haves: acceptance-run script exists; artifact populated with live results; manual-send boundary explicitly observed; Gmail continuity status recorded (even if blocked).
- Verification: `bash scripts/s06-acceptance-run.sh` && `test -s docs/s06-acceptance-run.md`
- Estimate: 1h30m
- [x] **T03: Added a repeatable live acceptance runner and recorded real S06 browser evidence for the manual-send boundary and daily loop.**
- Files: scripts/s06-acceptance-run.sh, docs/s06-acceptance-run.md, scripts/s06-preflight.sh, scripts/s06-acceptance-data.sh, job-tracker-ui/src/end-to-end-trust-loop.test.tsx
- Verify: bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md
@@ -5,78 +5,18 @@ milestone: M001
provides: []
requires: []
affects: []
key_files: [".gsd/milestones/M001/slices/S06/tasks/T01-SUMMARY.md", "scripts/s06-preflight.sh", "scripts/s06-preflight.test.sh", "README.md"]
key_decisions: ["Keep the preflight safe for shared terminals by never echoing bearer tokens and by treating admin-system auth failures as guided partial success with explicit token instructions."]
key_files: []
key_decisions: []
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Started the API with dotnet run --project JobTrackerApi/JobTrackerApi.csproj, then ran bash scripts/s06-preflight.sh against the live local API and confirmed the expected partial-pass behavior when /api/admin/system requires admin auth. Ran bash scripts/s06-preflight.test.sh to verify API-down, wrong API_BASE, and malformed JSON negative paths. Also checked README.md content for backend start, preflight command, origin pairing, and AUTH_TOKEN guidance."
completed_at: 2026-03-27T07:57:14.981Z
verification_result: ""
completed_at: 2026-03-28T22:02:57.778Z
blocker_discovered: false
---
# T01: Validated and recorded the live API/auth preflight gate, including README runbook guidance and negative-path shell coverage.
> Validated and recorded the live API/auth preflight gate, including README runbook guidance and negative-path shell coverage.
## What Happened
---
id: T01
parent: S06
milestone: M001
key_files:
- .gsd/milestones/M001/slices/S06/tasks/T01-SUMMARY.md
- scripts/s06-preflight.sh
- scripts/s06-preflight.test.sh
- README.md
key_decisions:
- Keep the preflight safe for shared terminals by never echoing bearer tokens and by treating admin-system auth failures as guided partial success with explicit token instructions.
duration: ""
verification_result: passed
completed_at: 2026-03-27T07:57:14.982Z
blocker_discovered: false
---
# T01: Validated and recorded the live API/auth preflight gate, including README runbook guidance and negative-path shell coverage.
**Validated and recorded the live API/auth preflight gate, including README runbook guidance and negative-path shell coverage.**
## What Happened
Verified that this worktree already contained the planned preflight implementation. The existing scripts/s06-preflight.sh probes /api/auth/config and /api/admin/system, honors API_BASE, prints auth/db/gmailConfigured/ai status surfaces, avoids leaking secrets, and gives readable failure guidance for unreachable API, malformed JSON, and admin-token-required responses. README.md already documented the backend start command, preflight invocation, AUTH_TOKEN note, and the required localhost UI/API origin pairing. This auto-fix attempt primarily addressed the missing task artifact on disk by writing T01-SUMMARY.md after re-running the real verification commands.
## Verification
Started the API with dotnet run --project JobTrackerApi/JobTrackerApi.csproj, then ran bash scripts/s06-preflight.sh against the live local API and confirmed the expected partial-pass behavior when /api/admin/system requires admin auth. Ran bash scripts/s06-preflight.test.sh to verify API-down, wrong API_BASE, and malformed JSON negative paths. Also checked README.md content for backend start, preflight command, origin pairing, and AUTH_TOKEN guidance.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `bash scripts/s06-preflight.sh` | 0 | ✅ pass | 123ms |
| 2 | `bash scripts/s06-preflight.test.sh` | 0 | ✅ pass | 1251ms |
| 3 | `python3 README content check for backend start, preflight command, origin pair, and token note` | 0 | ✅ pass | 0ms |
## Deviations
None. The implementation already matched the task plan in this worktree; this attempt restored the missing summary artifact and completion metadata.
## Known Issues
The local database in this environment currently has no seeded admin user, so the placeholder development credentials do not yield an admin bearer token here. Full /api/admin/system detail verification therefore still depends on valid admin credentials in the target environment, but the preflight script handles this by surfacing clear AUTH_TOKEN guidance and readiness placeholders.
## Files Created/Modified
- `.gsd/milestones/M001/slices/S06/tasks/T01-SUMMARY.md`
- `scripts/s06-preflight.sh`
- `scripts/s06-preflight.test.sh`
- `README.md`
## Deviations
None. The implementation already matched the task plan in this worktree; this attempt restored the missing summary artifact and completion metadata.
## Known Issues
The local database in this environment currently has no seeded admin user, so the placeholder development credentials do not yield an admin bearer token here. Full /api/admin/system detail verification therefore still depends on valid admin credentials in the target environment, but the preflight script handles this by surfacing clear AUTH_TOKEN guidance and readiness placeholders.
No summary recorded.
@@ -5,83 +5,18 @@ milestone: M001
provides: []
requires: []
affects: []
key_files: ["scripts/s06-acceptance-data.sh", "scripts/s06-acceptance-data.test.sh", "README.md", ".gsd/KNOWLEDGE.md", ".gsd/DECISIONS.md", ".gsd/milestones/M001/slices/S06/tasks/T02-SUMMARY.md"]
key_decisions: ["Seed acceptance data only through the live companies/jobapplications/correspondence API plus the dedicated tailored-cv, application-drafts, and followup endpoints, keyed by deterministic company/title/thread/message identifiers so reruns stay idempotent.", "Backdate both the seeded follow-up date and the latest correspondence timestamp past the active follow-up threshold so the acceptance fixture lands in workflowSignal.actionKey=follow-up instead of review-readiness."]
key_files: []
key_decisions: []
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Ran bash scripts/s06-acceptance-data.test.sh against the live API with a valid dev-signed bearer token to confirm missing-token, bad-token, and double-rerun behavior. Ran bash scripts/s06-acceptance-data.sh against the real backend and confirmed seed.result=success, a stable company/job fixture, one correspondence entry, seed.workflow.action=follow-up, seed.readiness.level=Ready, and seed.reminders=Waiting 14d. Verified README.md contains the acceptance-data runbook markers and token guidance."
completed_at: 2026-03-27T08:09:46.052Z
verification_result: ""
completed_at: 2026-03-28T22:02:57.778Z
blocker_discovered: false
---
# T02: Seeded acceptance-ready job data through the live API with deterministic rerun-safe ids and readiness output.
> Seeded acceptance-ready job data through the live API with deterministic rerun-safe ids and readiness output.
## What Happened
---
id: T02
parent: S06
milestone: M001
key_files:
- scripts/s06-acceptance-data.sh
- scripts/s06-acceptance-data.test.sh
- README.md
- .gsd/KNOWLEDGE.md
- .gsd/DECISIONS.md
- .gsd/milestones/M001/slices/S06/tasks/T02-SUMMARY.md
key_decisions:
- Seed acceptance data only through the live companies/jobapplications/correspondence API plus the dedicated tailored-cv, application-drafts, and followup endpoints, keyed by deterministic company/title/thread/message identifiers so reruns stay idempotent.
- Backdate both the seeded follow-up date and the latest correspondence timestamp past the active follow-up threshold so the acceptance fixture lands in workflowSignal.actionKey=follow-up instead of review-readiness.
duration: ""
verification_result: passed
completed_at: 2026-03-27T08:09:46.053Z
blocker_discovered: false
---
# T02: Seeded acceptance-ready job data through the live API with deterministic rerun-safe ids and readiness output.
**Seeded acceptance-ready job data through the live API with deterministic rerun-safe ids and readiness output.**
## What Happened
Implemented scripts/s06-acceptance-data.sh as an executable live seed flow that requires AUTH_TOKEN, runs the existing S06 preflight first, then creates or reuses a deterministic acceptance company and job via the real API, updates recruiter metadata, saves tailored CV and application package material through the dedicated endpoints, schedules an overdue follow-up, and maintains exactly one deterministic correspondence entry for the linked recruiter thread. Added scripts/s06-acceptance-data.test.sh to verify the missing-token and bad-token failure modes plus authenticated idempotent reruns, updated README.md with the seed command and token guidance, recorded the waiting-threshold gotcha in .gsd/KNOWLEDGE.md, and saved D013 in .gsd/DECISIONS.md for the live-API seeding pattern. After correcting the seeded activity dates to cross the real 14-day RulesEngine threshold, the live fixture now reports workflowSignal.actionKey=follow-up and Waiting 14d reminder output.
## Verification
Ran bash scripts/s06-acceptance-data.test.sh against the live API with a valid dev-signed bearer token to confirm missing-token, bad-token, and double-rerun behavior. Ran bash scripts/s06-acceptance-data.sh against the real backend and confirmed seed.result=success, a stable company/job fixture, one correspondence entry, seed.workflow.action=follow-up, seed.readiness.level=Ready, and seed.reminders=Waiting 14d. Verified README.md contains the acceptance-data runbook markers and token guidance.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `bash scripts/s06-acceptance-data.test.sh` | 0 | ✅ pass | 3885ms |
| 2 | `bash scripts/s06-acceptance-data.sh` | 0 | ✅ pass | 1831ms |
| 3 | `python3 README acceptance-data guidance check` | 0 | ✅ pass | 24ms |
## Deviations
Added scripts/s06-acceptance-data.test.sh and recorded one knowledge/decision entry beyond the plans expected output files so the negative-path and idempotence contract is executable and the follow-up-threshold gotcha is preserved for downstream work.
## Known Issues
The local seeded admin password from JobTrackerApi/appsettings.Development.json does not authenticate against this current DB snapshot, so the README and script comments document token retrieval in terms of the real local account or an already-authenticated browser session. Verification used a valid dev-signed JWT against the live API contract, which is sufficient for these user-scoped endpoints but does not prove the placeholder login credentials themselves.
## Files Created/Modified
- `scripts/s06-acceptance-data.sh`
- `scripts/s06-acceptance-data.test.sh`
- `README.md`
- `.gsd/KNOWLEDGE.md`
- `.gsd/DECISIONS.md`
- `.gsd/milestones/M001/slices/S06/tasks/T02-SUMMARY.md`
## Deviations
Added scripts/s06-acceptance-data.test.sh and recorded one knowledge/decision entry beyond the plans expected output files so the negative-path and idempotence contract is executable and the follow-up-threshold gotcha is preserved for downstream work.
## Known Issues
The local seeded admin password from JobTrackerApi/appsettings.Development.json does not authenticate against this current DB snapshot, so the README and script comments document token retrieval in terms of the real local account or an already-authenticated browser session. Verification used a valid dev-signed JWT against the live API contract, which is sufficient for these user-scoped endpoints but does not prove the placeholder login credentials themselves.
No summary recorded.
@@ -5,81 +5,18 @@ milestone: M001
provides: []
requires: []
affects: []
key_files: ["scripts/s06-acceptance-run.sh", "docs/s06-acceptance-run.md", ".gsd/KNOWLEDGE.md", ".gsd/DECISIONS.md", ".gsd/milestones/M001/slices/S06/tasks/T03-SUMMARY.md"]
key_decisions: ["Allow the S06 acceptance runner to mint a localhost-only admin JWT from the checked-in dev JWT settings plus the local SQLite admin record when AUTH_TOKEN is missing.", "Preserve the browser-observations section in docs/s06-acceptance-run.md across reruns so the verification command can refresh shell evidence without erasing manual UAT notes."]
key_files: []
key_decisions: []
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Verified bash scripts/s06-preflight.sh against the live API, reran bash scripts/s06-acceptance-data.sh using the locally minted admin token file, and passed bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md. In the browser, authenticated the local UI with the same localhost-only JWT model, confirmed the seeded acceptance job on /jobs and /reminders, confirmed dashboard analytics on /dashboard, and verified that opening the follow-up draft issued GET /api/jobapplications/3/followup-draft without any POST /api/jobapplications/3/send-followup. A clean dashboard reload also passed no_console_errors and no_failed_requests checks."
completed_at: 2026-03-27T08:24:16.594Z
verification_result: ""
completed_at: 2026-03-28T22:02:57.778Z
blocker_discovered: false
---
# T03: Added a repeatable live acceptance runner and recorded real S06 browser evidence for the manual-send boundary and daily loop.
> Added a repeatable live acceptance runner and recorded real S06 browser evidence for the manual-send boundary and daily loop.
## What Happened
---
id: T03
parent: S06
milestone: M001
key_files:
- scripts/s06-acceptance-run.sh
- docs/s06-acceptance-run.md
- .gsd/KNOWLEDGE.md
- .gsd/DECISIONS.md
- .gsd/milestones/M001/slices/S06/tasks/T03-SUMMARY.md
key_decisions:
- Allow the S06 acceptance runner to mint a localhost-only admin JWT from the checked-in dev JWT settings plus the local SQLite admin record when AUTH_TOKEN is missing.
- Preserve the browser-observations section in docs/s06-acceptance-run.md across reruns so the verification command can refresh shell evidence without erasing manual UAT notes.
duration: ""
verification_result: passed
completed_at: 2026-03-27T08:24:16.596Z
blocker_discovered: false
---
# T03: Added a repeatable live acceptance runner and recorded real S06 browser evidence for the manual-send boundary and daily loop.
**Added a repeatable live acceptance runner and recorded real S06 browser evidence for the manual-send boundary and daily loop.**
## What Happened
Built scripts/s06-acceptance-run.sh to orchestrate the live S06 acceptance rerun, capture preflight/seed/test logs, and keep docs/s06-acceptance-run.md current without overwriting the guided browser section. Added a localhost-only JWT fallback for the runner so the acceptance fixture and protected UI can be exercised repeatably even though the placeholder appsettings development password no longer authenticates against the current SQLite snapshot. Then ran the live browser flow against /jobs, the seeded workspace, /reminders, and /dashboard, captured debug bundles plus trace/timeline artifacts, and recorded the observed manual-send boundary and current Gmail-continuity limitation in the acceptance document.
## Verification
Verified bash scripts/s06-preflight.sh against the live API, reran bash scripts/s06-acceptance-data.sh using the locally minted admin token file, and passed bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md. In the browser, authenticated the local UI with the same localhost-only JWT model, confirmed the seeded acceptance job on /jobs and /reminders, confirmed dashboard analytics on /dashboard, and verified that opening the follow-up draft issued GET /api/jobapplications/3/followup-draft without any POST /api/jobapplications/3/send-followup. A clean dashboard reload also passed no_console_errors and no_failed_requests checks.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `bash scripts/s06-preflight.sh` | 0 | ✅ pass | 142ms |
| 2 | `AUTH_TOKEN="$(python3 - <<'PY' ... PY)" bash scripts/s06-acceptance-data.sh` | 0 | ✅ pass | 2301ms |
| 3 | `bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md` | 0 | ✅ pass | 5501ms |
## Deviations
Added a localhost-only JWT fallback inside scripts/s06-acceptance-run.sh because the current SQLite snapshot still contains the admin user while the placeholder development password no longer authenticates. This kept the task verification repeatable without changing scripts/s06-acceptance-data.sh's direct missing-token failure mode.
## Known Issues
This run did not prove a Gmail-connected continuity refresh. The seeded recruiter-thread correspondence is visible in the workspace, but the live browser session did not surface connected Gmail state or issue POST /api/gmail/refresh-linked-threads, so the acceptance artifact records Gmail continuity as not configured/not refreshed in this local run.
## Files Created/Modified
- `scripts/s06-acceptance-run.sh`
- `docs/s06-acceptance-run.md`
- `.gsd/KNOWLEDGE.md`
- `.gsd/DECISIONS.md`
- `.gsd/milestones/M001/slices/S06/tasks/T03-SUMMARY.md`
## Deviations
Added a localhost-only JWT fallback inside scripts/s06-acceptance-run.sh because the current SQLite snapshot still contains the admin user while the placeholder development password no longer authenticates. This kept the task verification repeatable without changing scripts/s06-acceptance-data.sh's direct missing-token failure mode.
## Known Issues
This run did not prove a Gmail-connected continuity refresh. The seeded recruiter-thread correspondence is visible in the workspace, but the live browser session did not surface connected Gmail state or issue POST /api/gmail/refresh-linked-threads, so the acceptance artifact records Gmail continuity as not configured/not refreshed in this local run.
No summary recorded.
+4 -7
View File
@@ -1,18 +1,15 @@
# S07: Daily-loop UAT artifact closure
**Goal:** Publish the executed daily-loop UAT closure artifact proving one seeded job stays coherent across /jobs, the job workspace, /reminders, and /dashboard using the existing S06 acceptance runner.
**Demo:** After this: The overview-surface/browser validation is captured as a real executed UAT artifact instead of a placeholder, proving the same job behaves coherently across table, dashboard, reminders, and workspace entry.
**Demo:** After this: TBD
## Tasks
- [x] **T01: Added docs/s07-uat.md to close S07 with imported acceptance-run evidence for the seeded daily-loop job.**Why: Give S07 its own UAT closure document that reuses the S06 acceptance runner as the evidence source and frames the daily-loop proof across /jobs → workspace → reminders → dashboard while preserving the manual-send and Gmail-continuity notes. Do: review docs/s06-acceptance-run.md and scripts/s06-acceptance-run.sh for generated/manual seams; create docs/s07-uat.md with sections for surfaces, job identity, manual-send boundary evidence, Gmail continuity status, rerun commands, and artifact links; note that evidence is imported from the acceptance run rather than duplicated. Done when docs/s07-uat.md exists with the sections above and references the seeded job (S06 Acceptance Backend Engineer) and acceptance artifact.
- Estimate: 45m
- [x] **T01: Added docs/s07-uat.md to close S07 with imported acceptance-run evidence for the seeded daily-loop job.**
- Files: docs/s07-uat.md, docs/s06-acceptance-run.md
- Verify: test -s docs/s07-uat.md && grep -q "S06 Acceptance Backend Engineer" docs/s07-uat.md
- [x] **T02: Re-ran the acceptance flow and refreshed the S07 UAT closure with current browser evidence, manual-send-boundary proof, and the Gmail continuity limitation.**Why: Refresh the live acceptance evidence and capture the manual-send boundary plus Gmail continuity status for S07. Do: run `bash scripts/s06-preflight.sh`; run `bash scripts/s06-acceptance-run.sh` (with AUTH_TOKEN if needed) to regenerate docs/s06-acceptance-run.md and artifacts; confirm the seeded job identity and cross-surface observations, and extract artifact links (logs, trace, timeline, screenshots/debug bundle); update docs/s07-uat.md with the latest evidence, explicitly stating the manual-send boundary (GET followup-draft seen, no POST send-followup) and the Gmail continuity limitation observed in this run. Failure modes: backend/API down → note preflight failure; auth/token missing → use runner fallback guidance; browser/assertion failures → capture log paths; malformed artifact paths → rerun and repair links. Negative checks: ensure acceptance run did not issue send-followup, and Gmail refresh absence is recorded, not implied passing. Done when both docs are updated with current run evidence and links.
- Estimate: 1h
- [x] **T02: Re-ran the acceptance flow and refreshed the S07 UAT closure with current browser evidence, manual-send-boundary proof, and the Gmail continuity limitation.**
- Files: scripts/s06-preflight.sh, scripts/s06-acceptance-run.sh, docs/s06-acceptance-run.md, docs/s07-uat.md
- Verify: bash scripts/s06-preflight.sh && bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md && grep -q "manual-send boundary" docs/s07-uat.md
- [x] **T03: Re-ran the focused daily-loop UI regressions, repaired the local CRA dependency state, and recorded the passing deterministic coverage in docs/s07-uat.md.**Why: Anchor the S07 UAT closure to the existing focused UI regressions that encode the cross-surface contract. Do: from job-tracker-ui/, run `CI=true npm test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx`; capture pass/fail summaries and note any flake; update docs/s07-uat.md with the test command, date/time, and results so the UAT doc cites both live run evidence and deterministic regression coverage. Failure modes: missing node modules → npm install; test failures → log failing test output and blockers in the doc. Negative tests: ensure the doc notes what happens if these tests fail (e.g., stop claiming UAT closure). Done when tests pass and docs/s07-uat.md reflects the run and command used.
- Estimate: 40m
- [x] **T03: Re-ran the focused daily-loop UI regressions, repaired the local CRA dependency state, and recorded the passing deterministic coverage in docs/s07-uat.md.**
- Files: job-tracker-ui/src/daily-control-loop.test.tsx, job-tracker-ui/src/workflow-trust-signals.test.tsx, docs/s07-uat.md
- Verify: CI=true npm --prefix job-tracker-ui test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx && grep -q "UI regression results" docs/s07-uat.md
@@ -5,72 +5,18 @@ milestone: M001
provides: []
requires: []
affects: []
key_files: ["docs/s07-uat.md", ".gsd/milestones/M001/slices/S07/tasks/T01-SUMMARY.md"]
key_decisions: ["Reused docs/s06-acceptance-run.md as the canonical execution artifact and positioned docs/s07-uat.md as an imported-evidence closure document instead of duplicating generated run output."]
key_files: []
key_decisions: []
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Verified the task-plan must-have by confirming docs/s07-uat.md exists, is non-empty, and contains the seeded job identity string S06 Acceptance Backend Engineer."
completed_at: 2026-03-27T08:36:36.287Z
verification_result: ""
completed_at: 2026-03-28T22:02:57.778Z
blocker_discovered: false
---
# T01: Added docs/s07-uat.md to close S07 with imported acceptance-run evidence for the seeded daily-loop job.
> Added docs/s07-uat.md to close S07 with imported acceptance-run evidence for the seeded daily-loop job.
## What Happened
---
id: T01
parent: S07
milestone: M001
key_files:
- docs/s07-uat.md
- .gsd/milestones/M001/slices/S07/tasks/T01-SUMMARY.md
key_decisions:
- Reused docs/s06-acceptance-run.md as the canonical execution artifact and positioned docs/s07-uat.md as an imported-evidence closure document instead of duplicating generated run output.
duration: ""
verification_result: passed
completed_at: 2026-03-27T08:36:36.289Z
blocker_discovered: false
---
# T01: Added docs/s07-uat.md to close S07 with imported acceptance-run evidence for the seeded daily-loop job.
**Added docs/s07-uat.md to close S07 with imported acceptance-run evidence for the seeded daily-loop job.**
## What Happened
Reviewed the S07 task contract together with docs/s06-acceptance-run.md and scripts/s06-acceptance-run.sh to confirm the runner-generated/manual seam. Created docs/s07-uat.md as a slice-specific closure artifact that references the canonical S06 acceptance run instead of duplicating it, names the seeded job S06 Acceptance Backend Engineer, summarizes the /jobs → workspace → /reminders → /dashboard coherence proof, preserves the manual-send boundary evidence, records the Gmail continuity limitation explicitly, and includes rerun commands plus artifact links.
## Verification
Verified the task-plan must-have by confirming docs/s07-uat.md exists, is non-empty, and contains the seeded job identity string S06 Acceptance Backend Engineer.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `test -s /home/pi/development/JobTracker/.gsd/worktrees/M001/docs/s07-uat.md && grep -q "S06 Acceptance Backend Engineer" /home/pi/development/JobTracker/.gsd/worktrees/M001/docs/s07-uat.md` | 0 | ✅ pass | 1ms |
## Deviations
None.
## Known Issues
None.
## Files Created/Modified
- `docs/s07-uat.md`
- `.gsd/milestones/M001/slices/S07/tasks/T01-SUMMARY.md`
## Deviations
None.
## Known Issues
None.
No summary recorded.
@@ -5,78 +5,18 @@ milestone: M001
provides: []
requires: []
affects: []
key_files: ["docs/s06-acceptance-run.md", "docs/s07-uat.md", ".gsd/milestones/M001/slices/S07/tasks/T02-SUMMARY.md"]
key_decisions: ["Recorded the manual-send boundary for this rerun as a combination of live workspace UI evidence plus an authenticated browser-context GET check to /api/jobapplications/3/followup-draft, because this build did not emit a fresh captured request merely from tab selection."]
key_files: []
key_decisions: []
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Verified the task contract in the live worktree by first reproducing the initial failure mode (bash scripts/s06-preflight.sh while the API was down), then starting the local API/UI, rerunning the acceptance scripts, and executing the browser flow across /jobs, workspace, /reminders, and /dashboard. The final shell gate passed with bash scripts/s06-preflight.sh && bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md && grep -q "manual-send boundary" docs/s07-uat.md. Browser assertions passed for the seeded reminders row, dashboard counters/company activity, and clean dashboard reload diagnostics (no console errors, no failed requests). The follow-up draft state showed separate copy/send controls, the authenticated browser-context GET to /api/jobapplications/3/followup-draft returned 200 with subject/body present, and no send-followup request was triggered during the observed browser pass."
completed_at: 2026-03-27T08:51:21.858Z
verification_result: ""
completed_at: 2026-03-28T22:02:57.778Z
blocker_discovered: false
---
# T02: Re-ran the acceptance flow and refreshed the S07 UAT closure with current browser evidence, manual-send-boundary proof, and the Gmail continuity limitation.
> Re-ran the acceptance flow and refreshed the S07 UAT closure with current browser evidence, manual-send-boundary proof, and the Gmail continuity limitation.
## What Happened
---
id: T02
parent: S07
milestone: M001
key_files:
- docs/s06-acceptance-run.md
- docs/s07-uat.md
- .gsd/milestones/M001/slices/S07/tasks/T02-SUMMARY.md
key_decisions:
- Recorded the manual-send boundary for this rerun as a combination of live workspace UI evidence plus an authenticated browser-context GET check to /api/jobapplications/3/followup-draft, because this build did not emit a fresh captured request merely from tab selection.
duration: ""
verification_result: mixed
completed_at: 2026-03-27T08:51:21.858Z
blocker_discovered: false
---
# T02: Re-ran the acceptance flow and refreshed the S07 UAT closure with current browser evidence, manual-send-boundary proof, and the Gmail continuity limitation.
**Re-ran the acceptance flow and refreshed the S07 UAT closure with current browser evidence, manual-send-boundary proof, and the Gmail continuity limitation.**
## What Happened
Started from the task contract and verified the actual runner/doc seam before changing anything. The first verification attempt failed immediately because the API was not listening on http://localhost:5202, so I treated that as an execution-state issue, started the real ASP.NET Core API and React UI from this worktree, and reran the acceptance flow instead of documenting a stale failure. With the stack live, I ran scripts/s06-preflight.sh and scripts/s06-acceptance-run.sh to refresh docs/s06-acceptance-run.md and the acceptance log set. I then exercised the real authenticated browser flow across /jobs, the seeded job workspace, /reminders, and /dashboard, and captured fresh artifacts for the jobs/workspace view, follow-up draft state, reminders surface, dashboard surface, Playwright trace, and browser timeline. The browser pass confirmed the seeded row S06 Acceptance Labs • S06 Acceptance Backend Engineer still appears on /jobs with Follow up, CV ready, and Waiting; the workspace still shows the saved tailored CV content and seeded recruiter-thread message; /reminders still shows Follow up, Waiting 14d, and Follow-up: 10/03/2026; and /dashboard still reports Active applications = 2, Applied (30 days) = 2, Responses logged = 1, and includes S06 Acceptance Labs under top companies by activity. For the manual-send boundary, the follow-up draft UI rendered separate Copy Draft and Send And Log Email actions in the live workspace. In this build, selecting the draft tab did not itself emit a newly captured request, so I verified the draft endpoint from the authenticated browser context with GET /api/jobapplications/3/followup-draft -> 200 and recorded that no POST /api/jobapplications/3/send-followup was triggered during the observed browser pass. I also kept the Gmail continuity claim honest: the seeded correspondence was visible, but the Linked Gmail thread continuity banner was not shown and no linked-thread refresh activity was observed, so the docs now call Gmail continuity a limitation for this run rather than implying success. Finally, I updated both docs/s06-acceptance-run.md and docs/s07-uat.md so they reference the final verified shell rerun (20260327T084839Z), the fresh browser artifacts from this task, the explicit manual-send boundary wording, and the Gmail continuity limitation.
## Verification
Verified the task contract in the live worktree by first reproducing the initial failure mode (bash scripts/s06-preflight.sh while the API was down), then starting the local API/UI, rerunning the acceptance scripts, and executing the browser flow across /jobs, workspace, /reminders, and /dashboard. The final shell gate passed with bash scripts/s06-preflight.sh && bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md && grep -q "manual-send boundary" docs/s07-uat.md. Browser assertions passed for the seeded reminders row, dashboard counters/company activity, and clean dashboard reload diagnostics (no console errors, no failed requests). The follow-up draft state showed separate copy/send controls, the authenticated browser-context GET to /api/jobapplications/3/followup-draft returned 200 with subject/body present, and no send-followup request was triggered during the observed browser pass.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `bash scripts/s06-preflight.sh` | 1 | ❌ fail | 0ms |
| 2 | `bash scripts/s06-preflight.sh && bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md && grep -q "manual-send boundary" docs/s07-uat.md` | 0 | ✅ pass | 5783ms |
| 3 | `browser_assert reminders: text_visible("S06 Acceptance Labs • S06 Acceptance Backend Engineer"), text_visible("Follow-up: 10/03/2026"), text_visible("Waiting 14d"), text_visible("Follow up")` | 0 | ✅ pass | 0ms |
| 4 | `browser_assert dashboard: text_visible("Active applications"), text_visible("Responses logged"), text_visible("S06 Acceptance Labs"), no_console_errors, no_failed_requests` | 0 | ✅ pass | 0ms |
| 5 | `browser_evaluate fetch('http://localhost:5202/api/jobapplications/3/followup-draft') with browser auth token` | 0 | ✅ pass | 0ms |
## Deviations
Started the API and UI locally before rerunning the task because the required backend was not listening on port 5202 at the start of execution. For browser auth, I used the same localhost-only JWT strategy already built into the acceptance runner because the checked-in placeholder password no longer authenticated against this DB snapshot. The manual-send section was documented as UI evidence plus a browser-context GET confirmation because this build did not emit a newly captured request solely from tab selection.
## Known Issues
The browser automation logs show a harmless early failed login attempt and an abandoned temporary-token experiment before the final auth path was corrected; those diagnostics were not used as acceptance evidence. Gmail continuity is still not proven live in this environment because the connected-Gmail banner/refresh activity did not surface during this run.
## Files Created/Modified
- `docs/s06-acceptance-run.md`
- `docs/s07-uat.md`
- `.gsd/milestones/M001/slices/S07/tasks/T02-SUMMARY.md`
## Deviations
Started the API and UI locally before rerunning the task because the required backend was not listening on port 5202 at the start of execution. For browser auth, I used the same localhost-only JWT strategy already built into the acceptance runner because the checked-in placeholder password no longer authenticated against this DB snapshot. The manual-send section was documented as UI evidence plus a browser-context GET confirmation because this build did not emit a newly captured request solely from tab selection.
## Known Issues
The browser automation logs show a harmless early failed login attempt and an abandoned temporary-token experiment before the final auth path was corrected; those diagnostics were not used as acceptance evidence. Gmail continuity is still not proven live in this environment because the connected-Gmail banner/refresh activity did not surface during this run.
No summary recorded.
@@ -5,79 +5,18 @@ milestone: M001
provides: []
requires: []
affects: []
key_files: ["docs/s07-uat.md", "job-tracker-ui/package-lock.json", ".gsd/KNOWLEDGE.md", ".gsd/milestones/M001/slices/S07/tasks/T03-SUMMARY.md"]
key_decisions: ["No new architectural or product decision was needed; this task refreshed the UAT closure with deterministic regression evidence and documented the dependency-repair prerequisite actually required in this worktree."]
key_files: []
key_decisions: []
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Verified the task contract by first reproducing the planned-command failure (`react-scripts: not found`), then repairing dependencies with `npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui install`, rerunning the focused Jest command successfully, and finally running the slice/task verification gate equivalent: `CI=true npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx && grep -q "UI regression results" /home/pi/development/JobTracker/.gsd/worktrees/M001/docs/s07-uat.md`. The final run passed with `2 passed, 2 total` suites and `6 passed, 6 total` tests."
completed_at: 2026-03-27T08:55:15.905Z
verification_result: ""
completed_at: 2026-03-28T22:02:57.779Z
blocker_discovered: false
---
# T03: Re-ran the focused daily-loop UI regressions, repaired the local CRA dependency state, and recorded the passing deterministic coverage in docs/s07-uat.md.
> Re-ran the focused daily-loop UI regressions, repaired the local CRA dependency state, and recorded the passing deterministic coverage in docs/s07-uat.md.
## What Happened
---
id: T03
parent: S07
milestone: M001
key_files:
- docs/s07-uat.md
- job-tracker-ui/package-lock.json
- .gsd/KNOWLEDGE.md
- .gsd/milestones/M001/slices/S07/tasks/T03-SUMMARY.md
key_decisions:
- No new architectural or product decision was needed; this task refreshed the UAT closure with deterministic regression evidence and documented the dependency-repair prerequisite actually required in this worktree.
duration: ""
verification_result: mixed
completed_at: 2026-03-27T08:55:15.906Z
blocker_discovered: false
---
# T03: Re-ran the focused daily-loop UI regressions, repaired the local CRA dependency state, and recorded the passing deterministic coverage in docs/s07-uat.md.
**Re-ran the focused daily-loop UI regressions, repaired the local CRA dependency state, and recorded the passing deterministic coverage in docs/s07-uat.md.**
## What Happened
Started from the task contract and verified the existing S07 UAT document before changing it. The first execution of the planned Jest command failed with the documented dependency failure mode: `react-scripts: not found`. I verified that this was a local install-state issue rather than a missing test target, repaired the UI package with `npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui install`, and reran the exact focused regression command from the plan. The rerun passed cleanly across both targeted suites, with only stable React Router future-flag warnings in console output. I then updated `docs/s07-uat.md` to add a dedicated UI regression results section that records the command used, the timestamp window, the initial failure/repair step, the final suite/test counts, the absence of observed flake, and the explicit guardrail that S07 UAT closure should not be claimed if this regression pair fails on a future rerun. Because the install-state quirk is non-obvious and likely to recur in this worktree, I also appended that rerun note to `.gsd/KNOWLEDGE.md`.
## Verification
Verified the task contract by first reproducing the planned-command failure (`react-scripts: not found`), then repairing dependencies with `npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui install`, rerunning the focused Jest command successfully, and finally running the slice/task verification gate equivalent: `CI=true npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx && grep -q "UI regression results" /home/pi/development/JobTracker/.gsd/worktrees/M001/docs/s07-uat.md`. The final run passed with `2 passed, 2 total` suites and `6 passed, 6 total` tests.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `CI=true npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx` | 127 | ❌ fail | 11500ms |
| 2 | `npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui install` | 0 | ✅ pass | 27000ms |
| 3 | `CI=true npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx` | 0 | ✅ pass | 5530ms |
| 4 | `CI=true npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx && grep -q "UI regression results" /home/pi/development/JobTracker/.gsd/worktrees/M001/docs/s07-uat.md` | 0 | ✅ pass | 4966ms |
## Deviations
The task plan assumed the focused UI tests could run immediately, but this worktree required a dependency repair first even though `node_modules` already existed. I documented that observed prerequisite in both the task summary and project knowledge instead of treating it as an unrecorded transient.
## Known Issues
The focused suites still emit React Router v7 future-flag warnings during render. They are non-failing and stable, so they did not block UAT closure evidence, but they remain visible noise in the deterministic regression output.
## Files Created/Modified
- `docs/s07-uat.md`
- `job-tracker-ui/package-lock.json`
- `.gsd/KNOWLEDGE.md`
- `.gsd/milestones/M001/slices/S07/tasks/T03-SUMMARY.md`
## Deviations
The task plan assumed the focused UI tests could run immediately, but this worktree required a dependency repair first even though `node_modules` already existed. I documented that observed prerequisite in both the task summary and project knowledge instead of treating it as an unrecorded transient.
## Known Issues
The focused suites still emit React Router v7 future-flag warnings during render. They are non-failing and stable, so they did not block UAT closure evidence, but they remain visible noise in the deterministic regression output.
No summary recorded.
+3361 -349
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -0,0 +1,9 @@
# CV Changes
## Requests
<!-- Add requested CV changes here as they come in -->
## Progress
- Created tracking file.
+16 -7
View File
@@ -17,6 +17,7 @@ namespace JobTrackerApi.Data
public DbSet<JobApplication> JobApplications => Set<JobApplication>();
public DbSet<Correspondence> Correspondences => Set<Correspondence>();
public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>();
public DbSet<GmailReviewDecision> GmailReviewDecisions => Set<GmailReviewDecision>();
public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
@@ -31,16 +32,16 @@ namespace JobTrackerApi.Data
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Company>()
.HasQueryFilter(c => CurrentUserId == null || c.OwnerUserId == CurrentUserId);
.HasQueryFilter(c => CurrentUserId != null && c.OwnerUserId == CurrentUserId);
modelBuilder.Entity<JobApplication>()
.HasQueryFilter(j => CurrentUserId == null || j.OwnerUserId == CurrentUserId);
.HasQueryFilter(j => CurrentUserId != null && j.OwnerUserId == CurrentUserId);
modelBuilder.Entity<UserRuleSettings>()
.HasKey(x => x.OwnerUserId);
modelBuilder.Entity<UserRuleSettings>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<RuleSettings>()
.HasData(new RuleSettings { Id = 1 });
@@ -64,7 +65,12 @@ namespace JobTrackerApi.Data
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<GmailConnection>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<GmailReviewDecision>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Ignore<CorrespondenceAttachmentMetadata>();
modelBuilder.Entity<GmailConnection>()
.HasIndex(x => new { x.OwnerUserId, x.GmailAddress })
@@ -79,6 +85,9 @@ namespace JobTrackerApi.Data
.HasForeignKey(a => a.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<JobEvent>()
.HasQueryFilter(x => CurrentUserId != null && x.JobApplication.OwnerUserId == CurrentUserId);
modelBuilder.Entity<JobEvent>()
.HasOne(e => e.JobApplication)
.WithMany(j => j.Events)
@@ -86,13 +95,13 @@ namespace JobTrackerApi.Data
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CvUploadArtifact>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CvUploadArtifact>()
.HasIndex(x => new { x.OwnerUserId, x.UploadedAtUtc });
modelBuilder.Entity<CvExtractionRun>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CvExtractionRun>()
.HasIndex(x => new { x.OwnerUserId, x.StartedAtUtc });
@@ -104,7 +113,7 @@ namespace JobTrackerApi.Data
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<TailoredCvDraft>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<TailoredCvDraft>()
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId })
+14
View File
@@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerApi", "JobTracker
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerApi.Tests", "JobTrackerApi.Tests\JobTrackerApi.Tests.csproj", "{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerBackend", "JobTrackerBackend\JobTrackerBackend.csproj", "{709F069F-DD13-42CC-9C5E-99923A545790}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -41,6 +43,18 @@ Global
{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x64.Build.0 = Release|Any CPU
{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x86.ActiveCfg = Release|Any CPU
{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x86.Build.0 = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|Any CPU.Build.0 = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x64.ActiveCfg = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x64.Build.0 = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x86.ActiveCfg = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x86.Build.0 = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|Any CPU.ActiveCfg = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|Any CPU.Build.0 = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x64.ActiveCfg = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x64.Build.0 = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x86.ActiveCfg = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1,12 +1,19 @@
using System.Reflection;
using JobTrackerApi.Controllers;
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class AttachmentsControllerTests
{
[Fact]
public void Controller_requires_local_authorization()
{
var attribute = typeof(AttachmentsController).GetCustomAttribute<Microsoft.AspNetCore.Authorization.AuthorizeAttribute>();
Assert.NotNull(attribute);
Assert.Equal("local", attribute!.AuthenticationSchemes);
}
[Fact]
public void Allowed_extensions_include_common_document_and_image_formats()
{
@@ -1,13 +1,13 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using System.Collections;
using System.Linq.Expressions;
@@ -91,13 +91,20 @@ public sealed class AuthAndSystemControllerTests
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones"));
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, NullLogger<AuthController>.Instance);
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, NullLogger<AuthController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<AuthController.AuthResult>(ok.Value);
Assert.Equal("app-token", payload.AccessToken);
var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
Assert.True(payload.Authenticated);
Assert.Equal("google", payload.Provider);
Assert.Equal("google-subject", user.GoogleSubject);
Assert.Equal("dj@cesnimda.co.uk", user.GoogleEmail);
Assert.NotNull(user.GoogleLinkedAt);
@@ -133,6 +140,35 @@ public sealed class AuthAndSystemControllerTests
Assert.Equal("person@example.com", result.GoogleLink.Email);
}
[Fact]
public async Task Admin_system_email_settings_falls_back_when_override_store_is_unavailable()
{
var emailSettings = new Mock<IEmailSettingsResolver>();
emailSettings.Setup(x => x.GetAdminDtoAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("missing SystemEmailSettings"));
var cfg = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Email:Enabled"] = "false",
["Email:FromName"] = "Jobbjakt"
})
.Build();
var controller = new AdminSystemController(
cfg,
new AppPaths(cfg, new FakeHostEnv()),
null!,
Mock.Of<ISummarizerService>(),
new FakeEnv(),
emailSettings.Object);
var result = await controller.GetEmailSettings(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<EmailSettingsAdminDto>(ok.Value);
Assert.False(dto.Enabled);
Assert.Contains("fallback", dto.FromName, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Admin_system_probe_endpoint_runs_probe_once()
{
@@ -162,18 +198,7 @@ public sealed class AuthAndSystemControllerTests
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object,
Options.Create(new IdentityOptions()),
new PasswordHasher<ApplicationUser>(),
Array.Empty<IUserValidator<ApplicationUser>>(),
Array.Empty<IPasswordValidator<ApplicationUser>>(),
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
null!,
new NullLogger<UserManager<ApplicationUser>>()
);
return TestHostFactory.CreateUserManager();
}
private sealed class TestAsyncQueryProvider<TEntity> : IAsyncQueryProvider
+20 -8
View File
@@ -1,18 +1,35 @@
using System.Reflection;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class BackupControllerTests
{
[Fact]
public void Backup_controller_requires_local_authorization()
{
var attribute = typeof(BackupController).GetCustomAttribute<AuthorizeAttribute>();
Assert.NotNull(attribute);
Assert.Equal("local", attribute!.AuthenticationSchemes);
}
[Fact]
public void Export_controller_requires_local_authorization()
{
var attribute = typeof(ExportController).GetCustomAttribute<AuthorizeAttribute>();
Assert.NotNull(attribute);
Assert.Equal("local", attribute!.AuthenticationSchemes);
}
[Fact]
public async Task Encrypted_returns_file_payload_on_non_windows_platforms_too()
{
@@ -34,11 +51,6 @@ public sealed class BackupControllerTests
private static JobTrackerContext CreateDb()
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(x => x.UserId).Returns("user-1");
return new JobTrackerContext(options, currentUser.Object);
return TestHostFactory.CreateInMemoryDb();
}
}
@@ -0,0 +1,103 @@
using System.Security.Claims;
using System.Text;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class ClientErrorsControllerTests
{
[Fact]
public void Report_logs_sanitized_payload_instead_of_raw_stacks()
{
var logger = new ListLogger<ClientErrorsController>();
var controller = new ClientErrorsController(logger);
var stack = "TypeError: bad\n at render(App.tsx:10)\nextra-secret-line";
var componentStack = "at Widget\n at Dashboard";
var result = controller.Report(new ClientErrorsController.ClientErrorReport(
ErrorId: " err-1 ",
Message: " boom ",
Stack: stack,
ComponentStack: componentStack,
Url: " https://jobtracker.test/jobs ",
UserAgent: " Browser\nAgent ",
At: " 2026-04-10T18:00:00Z "));
Assert.IsType<NoContentResult>(result);
var entry = Assert.Single(logger.Entries);
Assert.Contains("stackHash=", entry.Message);
Assert.Contains("componentHash=", entry.Message);
Assert.Contains("TypeError: bad | at render(App.tsx:10)", entry.Message);
Assert.DoesNotContain(stack, entry.Message);
Assert.DoesNotContain(componentStack, entry.Message);
Assert.DoesNotContain("extra-secret-line", entry.Message);
Assert.DoesNotContain("Browser\nAgent", entry.Message);
}
[Fact]
public async Task Upload_avatar_rejects_file_when_extension_or_detected_bytes_are_not_supported()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<ILogger<AuthController>>())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") }, "local")) }
}
};
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes("not really a png"));
IFormFile file = new FormFile(stream, 0, stream.Length, "file", "avatar.png")
{
Headers = new HeaderDictionary(),
ContentType = "image/png"
};
var result = await controller.UploadAvatar(file);
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Only PNG, JPEG, or WebP images are supported.", badRequest.Value);
userManager.Verify(x => x.UpdateAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
private static IConfiguration BuildConfig()
{
return new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
}
private sealed class ListLogger<T> : ILogger<T>
{
public List<LogEntry> Entries { get; } = new();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
Entries.Add(new LogEntry(logLevel, formatter(state, exception)));
}
}
private sealed record LogEntry(LogLevel Level, string Message);
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose() { }
}
}
+383
View File
@@ -0,0 +1,383 @@
using System.IO.Enumeration;
using System.Reflection;
using System.Security.Cryptography;
using System.Text.Json;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class CvCorpusHarnessTests
{
private static readonly string CorpusRoot = "/home/pi/cvs";
[Fact]
public async Task Local_cv_corpus_harness_produces_repeatable_parse_report_when_available()
{
if (!Directory.Exists(CorpusRoot)) return;
var ignoredPatterns = ResolveIgnoredPatterns();
var files = Directory.EnumerateFiles(CorpusRoot, "*.*", SearchOption.TopDirectoryOnly)
.Where(path => path.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
.Where(path => !IsIgnoredFile(path, ignoredPatterns))
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToList();
if (files.Count == 0) return;
var outputRoot = ResolveOutputRoot();
var outputsDir = Path.Combine(outputRoot, "outputs");
var candidateFixturesDir = Path.Combine(outputRoot, "candidate-fixtures");
var approvedFixturesDir = ResolveApprovedFixturesRoot(outputRoot);
Directory.CreateDirectory(outputRoot);
Directory.CreateDirectory(outputsDir);
Directory.CreateDirectory(candidateFixturesDir);
Directory.CreateDirectory(approvedFixturesDir);
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "seed" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900)).ReturnsAsync(string.Empty);
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Reconstruct this CV text extracted from a PDF", StringComparison.Ordinal)), It.IsAny<string>(), 2800, 900)).ReturnsAsync((string _, string text, int _, int __) => text);
var cvAiNormalizer = CreateCvAiNormalizerFromEnvironment();
await using var db = TestHostFactory.CreateInMemoryDb();
var paths = CreatePaths(outputRoot);
var controller = new ProfileCvController(userManager.Object, aiService.Object, db, paths, null, NoOpCvAiClassifier.Instance, cvAiNormalizer)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var extractMethod = typeof(ProfileCvController).GetMethod("ExtractTextAsync", BindingFlags.NonPublic | BindingFlags.Static);
var reconstructMethod = typeof(ProfileCvController).GetMethod("MaybeReconstructStructuredCvAsync", BindingFlags.NonPublic | BindingFlags.Instance);
var buildMethod = typeof(ProfileCvController).GetMethod("BuildStructuredCvAsync", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(extractMethod);
Assert.NotNull(reconstructMethod);
Assert.NotNull(buildMethod);
var entries = new List<CvBenchmarkEntry>();
foreach (var path in files)
{
await using var stream = File.OpenRead(path);
var fileName = Path.GetFileName(path);
var formFile = new FormFile(stream, 0, stream.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = GuessContentType(path)
};
var extension = Path.GetExtension(path);
var extractTask = (Task<string>)extractMethod!.Invoke(null, new object[] { formFile, extension })!;
var text = await extractTask;
Assert.False(string.IsNullOrWhiteSpace(text));
var reconstructTask = (Task<string>)reconstructMethod!.Invoke(controller, new object[] { text, CancellationToken.None })!;
var normalizedText = await reconstructTask;
Assert.False(string.IsNullOrWhiteSpace(normalizedText));
var buildTask = (Task<StructuredCvProfile>)buildMethod!.Invoke(controller, new object[] { normalizedText, CancellationToken.None })!;
var structured = StructuredCvProfileJson.Normalize(await buildTask);
Assert.NotNull(structured);
var slug = Slugify(fileName);
var normalizedJson = StructuredCvProfileJson.Serialize(structured);
var outputPath = Path.Combine(outputsDir, $"{slug}.json");
await File.WriteAllTextAsync(outputPath, PrettyJson(normalizedJson));
var approvedPath = Path.Combine(approvedFixturesDir, $"{slug}.json");
var candidateFixturePath = Path.Combine(candidateFixturesDir, $"{slug}.json");
string? diffSummary = null;
var approvedExists = File.Exists(approvedPath);
if (approvedExists)
{
var approvedJson = await File.ReadAllTextAsync(approvedPath);
diffSummary = SummarizeDiff(approvedJson, normalizedJson);
}
else
{
await File.WriteAllTextAsync(candidateFixturePath, PrettyJson(normalizedJson));
diffSummary = "No approved fixture yet — candidate fixture written.";
}
entries.Add(new CvBenchmarkEntry(
FileName: fileName,
Slug: slug,
Extension: extension,
Characters: text.Length,
OutputPath: outputPath,
ApprovedFixturePath: approvedExists ? approvedPath : null,
CandidateFixturePath: approvedExists ? null : candidateFixturePath,
ContactLocation: structured.Contact.Location,
FirstJob: structured.Jobs.FirstOrDefault()?.Title,
FirstJobLocation: structured.Jobs.FirstOrDefault()?.Location,
FirstEducation: structured.Education.FirstOrDefault()?.Qualification,
FirstEducationLocation: structured.Education.FirstOrDefault()?.Location,
QualificationLevels: structured.Education.Select(x => x.QualificationLevel).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList(),
SuspiciousLocations: structured.Jobs.Select(job => job.Location)
.Concat(structured.Education.Select(education => education.Location))
.Append(structured.Contact.Location)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Cast<string>()
.Where(LooksSuspiciousLocation)
.ToList(),
CoverageScore: ComputeCoverageScore(structured),
ConfidenceScore: ComputeConfidenceScore(structured),
ConsistencyScore: ComputeConsistencyScore(structured),
DiffSummary: diffSummary
));
}
var summary = new CvBenchmarkSummary(
CorpusRoot,
outputRoot,
DateTimeOffset.UtcNow,
entries.Count,
Math.Round(entries.Average(x => x.CoverageScore), 3),
Math.Round(entries.Average(x => x.ConfidenceScore), 3),
Math.Round(entries.Average(x => x.ConsistencyScore), 3),
entries.Count(x => x.SuspiciousLocations.Count > 0),
entries.Count(x => x.ApprovedFixturePath is null),
entries
);
var indexPath = Path.Combine(outputRoot, "index.json");
var reportPath = Path.Combine(outputRoot, "report.md");
await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true }));
await File.WriteAllTextAsync(reportPath, RenderMarkdownReport(summary));
Assert.True(entries.Count > 0);
}
private sealed record CvBenchmarkEntry(
string FileName,
string Slug,
string Extension,
int Characters,
string OutputPath,
string? ApprovedFixturePath,
string? CandidateFixturePath,
string? ContactLocation,
string? FirstJob,
string? FirstJobLocation,
string? FirstEducation,
string? FirstEducationLocation,
List<string> QualificationLevels,
List<string> SuspiciousLocations,
double CoverageScore,
double ConfidenceScore,
double ConsistencyScore,
string? DiffSummary);
private sealed record CvBenchmarkSummary(
string CorpusRoot,
string OutputRoot,
DateTimeOffset GeneratedAtUtc,
int TotalFiles,
double AverageCoverage,
double AverageConfidence,
double AverageConsistency,
int FilesWithSuspiciousLocations,
int MissingApprovedFixtures,
List<CvBenchmarkEntry> Entries);
private static string ResolveOutputRoot()
{
var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_OUTPUT_DIR");
if (!string.IsNullOrWhiteSpace(configured)) return configured.Trim();
return Path.Combine(Path.GetTempPath(), "jobtracker-cv-benchmark", DateTime.UtcNow.ToString("yyyyMMddHHmmss"));
}
private static string ResolveApprovedFixturesRoot(string outputRoot)
{
var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_APPROVED_DIR");
if (!string.IsNullOrWhiteSpace(configured)) return configured.Trim();
return Path.Combine(outputRoot, "approved-fixtures");
}
private static List<string> ResolveIgnoredPatterns()
{
var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_IGNORE");
if (string.IsNullOrWhiteSpace(configured)) return new List<string>();
return configured
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(value => !string.IsNullOrWhiteSpace(value))
.ToList();
}
private static bool IsIgnoredFile(string path, List<string> ignoredPatterns)
{
if (ignoredPatterns.Count == 0) return false;
var fileName = Path.GetFileName(path);
foreach (var pattern in ignoredPatterns)
{
if (FileSystemName.MatchesSimpleExpression(pattern, fileName, ignoreCase: true))
{
return true;
}
}
return false;
}
private static string PrettyJson(string normalizedJson)
{
using var doc = JsonDocument.Parse(normalizedJson);
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
}
private static string SummarizeDiff(string approvedJson, string actualJson)
{
if (JsonDocument.Parse(approvedJson).RootElement.ToString() == JsonDocument.Parse(actualJson).RootElement.ToString())
{
return "Matches approved fixture.";
}
var approvedHash = Hash(approvedJson);
var actualHash = Hash(actualJson);
return $"Fixture differs (approved {approvedHash[..8]}, actual {actualHash[..8]}).";
}
private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static double ComputeCoverageScore(StructuredCvProfile structured)
{
var signals = new[]
{
!string.IsNullOrWhiteSpace(structured.Contact.FullName),
!string.IsNullOrWhiteSpace(structured.Contact.Email),
!string.IsNullOrWhiteSpace(structured.Contact.Location),
structured.Summary.Count > 0,
structured.Skills.Count > 0,
structured.Jobs.Count > 0,
structured.Education.Count > 0,
structured.Certifications.Count > 0 || structured.Projects.Count > 0 || structured.OtherSections.Count > 0,
};
return signals.Count(x => x) / (double)signals.Length;
}
private static double ComputeConfidenceScore(StructuredCvProfile structured)
{
var confidences = structured.Metadata.Fields.Values.Select(x => x.Confidence).Where(x => x.HasValue).Select(x => x!.Value).ToList();
return confidences.Count == 0 ? 0.55 : Math.Clamp(confidences.Average(), 0, 1);
}
private static double ComputeConsistencyScore(StructuredCvProfile structured)
{
var penalties = 0;
penalties += structured.Jobs.Count(job => LooksSuspiciousLocation(job.Location));
penalties += structured.Education.Count(education => LooksSuspiciousLocation(education.Location));
penalties += LooksSuspiciousLocation(structured.Contact.Location) ? 1 : 0;
penalties += structured.Education.Count(education => string.IsNullOrWhiteSpace(education.QualificationLevel) && !string.IsNullOrWhiteSpace(education.Qualification));
return Math.Max(0, 1 - (penalties * 0.12));
}
private static string RenderMarkdownReport(CvBenchmarkSummary summary)
{
var lines = new List<string>
{
"# CV benchmark report",
string.Empty,
$"- Generated: {summary.GeneratedAtUtc:O}",
$"- Corpus root: `{summary.CorpusRoot}`",
$"- Output root: `{summary.OutputRoot}`",
$"- Files: {summary.TotalFiles}",
$"- Average coverage: {summary.AverageCoverage:P0}",
$"- Average confidence: {summary.AverageConfidence:P0}",
$"- Average consistency: {summary.AverageConsistency:P0}",
$"- Files with suspicious locations: {summary.FilesWithSuspiciousLocations}",
$"- Missing approved fixtures: {summary.MissingApprovedFixtures}",
string.Empty,
"| File | Coverage | Confidence | Consistency | Suspicious locations | Fixture |",
"|---|---:|---:|---:|---:|---|",
};
lines.AddRange(summary.Entries.Select(entry =>
$"| {entry.FileName} | {entry.CoverageScore:P0} | {entry.ConfidenceScore:P0} | {entry.ConsistencyScore:P0} | {entry.SuspiciousLocations.Count} | {entry.DiffSummary} |"));
lines.Add(string.Empty);
lines.Add("## Notes");
lines.Add("- `outputs/*.json` contains the latest normalized parser output for each CV.");
lines.Add("- `candidate-fixtures/*.json` is created when no approved fixture exists yet.");
lines.Add("- To build a regression baseline, review a candidate fixture and copy it into the approved-fixtures directory used by the runner.");
return string.Join(Environment.NewLine, lines);
}
private static string Slugify(string value)
{
var cleaned = new string((value ?? string.Empty).ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray());
while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal);
return cleaned.Trim('-');
}
private static bool LooksSuspiciousLocation(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
return value.Contains("Python", StringComparison.OrdinalIgnoreCase)
|| value.Contains("Ruby", StringComparison.OrdinalIgnoreCase)
|| value.Contains(" S A L E S ", StringComparison.OrdinalIgnoreCase)
|| value.Any(char.IsDigit);
}
private static string GuessContentType(string path)
{
return Path.GetExtension(path).ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".md" => "text/markdown",
_ => "text/plain"
};
}
private static AppPaths CreatePaths(string outputRoot)
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-corpus-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Data:Root"] = tempRoot,
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts"),
["Data:CvBenchmarksRoot"] = outputRoot,
})
.Build();
var env = new Mock<IHostEnvironment>();
env.SetupGet(x => x.ContentRootPath).Returns(tempRoot);
return new AppPaths(config, env.Object);
}
private static ICvAiNormalizer CreateCvAiNormalizerFromEnvironment()
{
var baseUrl = Environment.GetEnvironmentVariable("CV_AI_BASE_URL");
if (string.IsNullOrWhiteSpace(baseUrl)) return NoOpCvAiNormalizer.Instance;
var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
services.AddHttpClient("ai-service", client =>
{
client.BaseAddress = new Uri(baseUrl.Trim());
client.Timeout = TimeSpan.FromSeconds(180);
});
var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<System.Net.Http.IHttpClientFactory>();
return new CvAiNormalizer(factory);
}
}
+415 -11
View File
@@ -3,6 +3,7 @@ using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -14,6 +15,39 @@ namespace JobTrackerApi.Tests;
public sealed class GmailControllerTests
{
[Fact]
public async Task Status_returns_sync_state_fields_for_connected_account()
{
await using var db = CreateDb();
var gmail = new Mock<IGmailOAuthService>();
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailConnection
{
OwnerUserId = "user-1",
GmailAddress = "user@example.test",
ConnectedAt = DateTimeOffset.UtcNow.AddDays(-3),
LastSyncedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
LastSyncAttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastSyncSucceededAt = DateTimeOffset.UtcNow.AddMinutes(-10),
LastSyncMode = "list-messages",
LastSyncSource = "custom-query",
LastSyncStatus = "error",
LastSyncError = "Token refresh failed"
});
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.Status(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailConnectionStatusDto>(ok.Value);
Assert.True(payload.Connected);
Assert.Equal("user@example.test", payload.GmailAddress);
Assert.Equal("list-messages", payload.LastSyncMode);
Assert.Equal("custom-query", payload.LastSyncSource);
Assert.Equal("error", payload.LastSyncStatus);
Assert.Equal("Token refresh failed", payload.LastSyncError);
}
[Fact]
public async Task Import_thread_rejects_missing_message_ids()
{
@@ -224,7 +258,9 @@ public sealed class GmailControllerTests
DateTimeOffset.UtcNow.AddDays(-1),
"Snippet",
"Body text",
null));
null,
new[] { "INBOX", "IMPORTANT" },
new[] { new GmailMessageAttachment("cv.pdf", "application/pdf", 2048, "att-1", false) }));
var controller = CreateController(db, gmail.Object, "user-1");
@@ -238,6 +274,10 @@ public sealed class GmailControllerTests
Assert.Equal("thread-1", firstPayload.Message!.ExternalThreadId);
Assert.Equal("Maria Recruiter <maria@acme.test>", firstPayload.Message.ExternalFrom);
Assert.Equal("user@example.test", firstPayload.Message.ExternalTo);
Assert.Equal("inbound", firstPayload.Message.Direction);
Assert.Contains("IMPORTANT", firstPayload.Message.ExternalLabels);
Assert.Single(firstPayload.Message.AttachmentMetadata);
Assert.Equal("cv.pdf", firstPayload.Message.AttachmentMetadata[0].FileName);
var second = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
@@ -281,7 +321,9 @@ public sealed class GmailControllerTests
DateTimeOffset.UtcNow.AddDays(-1),
"Snippet 1",
"Body text 1",
null));
null,
Array.Empty<string>(),
Array.Empty<GmailMessageAttachment>()));
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-2", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailMessageDetail(
"msg-2",
@@ -292,7 +334,9 @@ public sealed class GmailControllerTests
DateTimeOffset.UtcNow,
"Snippet 2",
"Body text 2",
null));
null,
Array.Empty<string>(),
Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1");
var request = new GmailController.ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" });
@@ -364,7 +408,9 @@ public sealed class GmailControllerTests
DateTimeOffset.UtcNow,
"New reply",
"Reply body",
null));
null,
Array.Empty<string>(),
Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(job.Id), CancellationToken.None);
@@ -434,6 +480,59 @@ public sealed class GmailControllerTests
gmail.Verify(service => service.ListThreadMessagesAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Review_candidates_returns_threads_grouped_with_routing_summary()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", RecruiterEmail = "maria@acme.test", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication
{
JobTitle = "Backend Developer",
CompanyId = company.Id,
OwnerUserId = "user-1"
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var gmail = new Mock<IGmailOAuthService>();
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailConnection
{
OwnerUserId = "user-1",
GmailAddress = "user@example.test",
ConnectedAt = DateTimeOffset.UtcNow.AddDays(-1),
Scope = "gmail.readonly"
});
gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailQueryMatchedMessage(
new GmailMessageSummary(
"msg-top",
"thread-top",
"Backend Developer interview",
"Maria Recruiter <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-2),
"Acme wants to schedule a backend developer interview."),
new[] { "\"Acme\" \"Backend Developer\" newer_than:365d" })
});
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.ReviewCandidates(null, 6, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailReviewQueueResponseDto>(ok.Value);
Assert.Equal(1, payload.CandidateThreadCount);
Assert.Single(payload.Threads);
Assert.Equal("thread-top", payload.Threads[0].ThreadId);
Assert.True(payload.Threads[0].JobCandidates.Count > 0);
Assert.Contains(payload.Threads[0].Routing, new[] { "auto-link", "review", "unmatched" });
}
[Fact]
public async Task Refresh_linked_threads_rejects_invalid_job_id()
{
@@ -499,9 +598,319 @@ public sealed class GmailControllerTests
gmail.Verify(service => service.ListJobCandidateMessagesAsync(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Save_review_decision_links_thread_and_imports_messages()
{
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 Developer",
CompanyId = company.Id,
OwnerUserId = "user-1"
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var gmail = new Mock<IGmailOAuthService>();
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailMessageSummary("msg-1", "thread-1", "Backend Developer interview", "Maria Recruiter <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Interview invite")
});
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailMessageDetail(
"msg-1",
"thread-1",
"Backend Developer interview",
"Maria Recruiter <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-1),
"Interview invite",
"Body text",
null,
new[] { "INBOX" },
Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.SaveReviewDecision(new GmailController.SaveGmailReviewDecisionRequest("thread-1", "linked", job.Id, "Strong recruiter match"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var decision = await db.GmailReviewDecisions.SingleAsync();
Assert.Equal("linked", decision.Decision);
Assert.Equal(job.Id, decision.JobApplicationId);
Assert.Equal("Strong recruiter match", decision.Note);
var imported = await db.Correspondences.SingleAsync();
Assert.Equal("thread-1", imported.ExternalThreadId);
Assert.Equal("msg-1", imported.ExternalMessageId);
Assert.NotNull(ok.Value);
}
[Fact]
public async Task Manual_sync_auto_links_high_confidence_thread()
{
await using var db = CreateDb();
var company = new Company
{
Name = "Acme",
RecruiterEmail = "maria@acme.test",
OwnerUserId = "user-1"
};
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication
{
JobTitle = "Backend Developer",
CompanyId = company.Id,
OwnerUserId = "user-1"
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var gmail = new Mock<IGmailOAuthService>();
gmail.Setup(service => service.ListJobCandidateMessagesAsync(
"user-1",
It.Is<IEnumerable<string>>(queries => queries.Any(query => query.Contains("-in:spam")) && queries.Any(query => query.Contains("-in:trash")) && queries.All(query => query.Contains("newer_than:365d"))),
8,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailQueryMatchedMessage(
new GmailMessageSummary(
"msg-1",
"thread-1",
"Backend Developer interview",
"Maria Recruiter <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-2),
"Acme wants to schedule a backend developer interview."),
new[]
{
"\"Acme\" \"Backend Developer\" newer_than:365d -in:spam -in:trash",
"(from:maria@acme.test OR to:maria@acme.test) newer_than:365d -in:spam -in:trash"
})
});
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailMessageSummary("msg-1", "thread-1", "Backend Developer interview", "Maria Recruiter <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-2), "Invite")
});
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailMessageDetail(
"msg-1",
"thread-1",
"Backend Developer interview",
"Maria Recruiter <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-2),
"Invite",
"Interview details",
null,
new[] { "INBOX" },
Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.ManualSync(new GmailController.GmailManualSyncRequest(365, 8, true, false), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailManualSyncResultDto>(ok.Value);
Assert.Equal(1, payload.AutoLinkedThreadCount);
Assert.Equal(1, payload.ImportedThreads);
Assert.Equal(1, payload.ImportedMessages);
var decision = await db.GmailReviewDecisions.SingleAsync();
Assert.Equal("linked", decision.Decision);
Assert.Equal(job.Id, decision.JobApplicationId);
}
[Fact]
public async Task Suggested_jobs_and_create_suggested_job_create_job_and_link_thread()
{
await using var db = CreateDb();
var gmail = new Mock<IGmailOAuthService>();
gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<GmailQueryMatchedMessage>());
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-suggested", It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailMessageSummary("msg-s1", "thread-suggested", "Platform Engineer interview", "Nina Recruiter <nina@beta.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Let's talk about the role")
});
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-s1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailMessageDetail(
"msg-s1",
"thread-suggested",
"Platform Engineer interview",
"Nina Recruiter <nina@beta.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-1),
"Let's talk about the role",
"Interview details",
null,
new[] { "INBOX" },
Array.Empty<GmailMessageAttachment>()));
db.GmailReviewDecisions.Add(new GmailReviewDecision
{
OwnerUserId = "user-1",
ThreadId = "thread-suggested",
Decision = "suggested",
UpdatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
var controller = CreateController(db, gmail.Object, "user-1");
var reviewQueue = new GmailController.GmailReviewQueueResponseDto(
Array.Empty<string>(),
1,
0,
0,
1,
new[]
{
new GmailController.GmailReviewThreadDto(
"thread-suggested",
"Platform Engineer interview",
DateTimeOffset.UtcNow.AddDays(-1),
1,
"suggested",
false,
null,
Array.Empty<string>(),
Array.Empty<GmailController.GmailReviewJobCandidateDto>(),
new[]
{
new GmailController.GmailJobMatchedMessageDto(
"msg-s1",
"thread-suggested",
"Platform Engineer interview",
"Nina Recruiter <nina@beta.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-1),
"Let's talk about the role",
0,
"low",
false,
Array.Empty<string>(),
Array.Empty<GmailController.GmailJobMatchReasonDto>())
})
});
var suggested = Assert.IsType<OkObjectResult>((await controller.SuggestedJobs(CancellationToken.None)).Result);
Assert.IsType<GmailController.GmailSuggestedJobsResponseDto>(suggested.Value);
var create = await controller.CreateSuggestedJob(new GmailController.CreateSuggestedGmailJobRequest("thread-suggested", "Beta", "Platform Engineer", "Nina Recruiter", "nina@beta.test", "Create from Gmail suggestion", "Applied"), CancellationToken.None);
var createOk = Assert.IsType<OkObjectResult>(create.Result);
var created = Assert.IsType<GmailController.CreatedSuggestedGmailJobDto>(createOk.Value);
Assert.True(created.JobApplicationId > 0);
Assert.Equal(1, created.Imported);
Assert.Equal("thread-suggested", created.ThreadId);
Assert.Equal(1, await db.JobApplications.CountAsync());
Assert.Equal(1, await db.Correspondences.CountAsync());
}
[Fact]
public async Task Unlink_thread_removes_messages_and_sets_review_decision()
{
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 Developer", CompanyId = company.Id, OwnerUserId = "user-1" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
db.Correspondences.AddRange(
new Correspondence { JobApplicationId = job.Id, From = "Company", Content = "First", ExternalMessageId = "msg-1", ExternalThreadId = "thread-1" },
new Correspondence { JobApplicationId = job.Id, From = "Me", Content = "Second", ExternalMessageId = "msg-2", ExternalThreadId = "thread-1" });
await db.SaveChangesAsync();
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
var result = await controller.UnlinkThread(new GmailController.UnlinkGmailThreadRequest(job.Id, "thread-1", "Need manual review", "review"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailUnlinkResultDto>(ok.Value);
Assert.Equal(2, payload.RemovedMessages);
Assert.Equal("review", payload.Decision);
Assert.Empty(await db.Correspondences.ToListAsync());
var decision = await db.GmailReviewDecisions.SingleAsync();
Assert.Equal("review", decision.Decision);
Assert.Equal("Need manual review", decision.Note);
}
[Fact]
public async Task Relink_thread_can_move_messages_from_other_jobs()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var sourceJob = new JobApplication { JobTitle = "Source", CompanyId = company.Id, OwnerUserId = "user-1" };
var targetJob = new JobApplication { JobTitle = "Target", CompanyId = company.Id, OwnerUserId = "user-1" };
db.JobApplications.AddRange(sourceJob, targetJob);
await db.SaveChangesAsync();
db.Correspondences.Add(new Correspondence
{
JobApplicationId = sourceJob.Id,
From = "Company",
Content = "Existing import",
ExternalMessageId = "msg-1",
ExternalThreadId = "thread-1"
});
await db.SaveChangesAsync();
var gmail = new Mock<IGmailOAuthService>();
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailMessageSummary("msg-1", "thread-1", "Interview", "Maria <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow, "Snippet")
});
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailMessageDetail(
"msg-1",
"thread-1",
"Interview",
"Maria <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow,
"Snippet",
"Body",
null,
Array.Empty<string>(),
Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.RelinkThread(new GmailController.RelinkGmailThreadRequest(targetJob.Id, "thread-1", true, "Move to target"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailRelinkResultDto>(ok.Value);
Assert.Equal(1, payload.UnlinkedMessages);
Assert.Equal(1, payload.Imported);
var stored = await db.Correspondences.SingleAsync();
Assert.Equal(targetJob.Id, stored.JobApplicationId);
Assert.Equal("thread-1", stored.ExternalThreadId);
var decision = await db.GmailReviewDecisions.SingleAsync();
Assert.Equal(targetJob.Id, decision.JobApplicationId);
Assert.Equal("linked", decision.Decision);
}
private static GmailController CreateController(JobTrackerContext db, IGmailOAuthService gmail, string userId)
{
var controller = new GmailController(gmail, db, BuildConfig())
var controller = new GmailController(gmail, new GmailJobMatchingService(), db, BuildConfig())
{
ControllerContext = new ControllerContext
{
@@ -520,12 +929,7 @@ public sealed class GmailControllerTests
private static JobTrackerContext CreateDb()
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns("user-1");
return new JobTrackerContext(options, currentUser.Object);
return TestHostFactory.CreateInMemoryDb();
}
private static Microsoft.Extensions.Configuration.IConfiguration BuildConfig()
@@ -3,12 +3,12 @@ using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
@@ -475,29 +475,12 @@ public sealed class JobApplicationsApplicationPackageTests
private static JobTrackerContext CreateDb()
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns("user-1");
return new JobTrackerContext(options, currentUser.Object);
return TestHostFactory.CreateInMemoryDb();
}
private static Mock<UserManager<ApplicationUser>> CreateUserManager(ApplicationUser? user = null)
{
var store = new Mock<IUserStore<ApplicationUser>>();
var manager = new Mock<UserManager<ApplicationUser>>(
store.Object,
Options.Create(new IdentityOptions()),
new PasswordHasher<ApplicationUser>(),
Array.Empty<IUserValidator<ApplicationUser>>(),
Array.Empty<IPasswordValidator<ApplicationUser>>(),
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
null!,
new NullLogger<UserManager<ApplicationUser>>());
manager.Setup(x => x.FindByIdAsync(It.IsAny<string>())).ReturnsAsync((string id) => user is not null && user.Id == id ? user : null);
return manager;
return TestHostFactory.CreateUserManager(user);
}
private sealed class TestCvTemplateRenderer : ICvTemplateRenderer
@@ -0,0 +1,63 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class JobApplicationsAuthorizationTests
{
[Fact]
public async Task GetHistory_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();
var job = new JobApplication { JobTitle = "Secret Job", CompanyId = company.Id, OwnerUserId = "owner-1" };
ownerDb.JobApplications.Add(job);
await ownerDb.SaveChangesAsync();
ownerDb.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", Note = "owner only" });
await ownerDb.SaveChangesAsync();
await using var attackerDb = CreateDb(dbName, "other-user");
var controller = CreateController(attackerDb);
var result = await controller.GetHistory(job.Id, CancellationToken.None);
Assert.IsType<NotFoundResult>(result.Result);
}
private static JobTrackerContext CreateDb(string dbName, string? userId)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(dbName)
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns(userId);
return new JobTrackerContext(options, currentUser.Object);
}
private static JobApplicationsController CreateController(JobTrackerContext db)
{
var summarizer = new Mock<ISummarizerService>();
var users = TestHostFactory.CreateUserManager();
return new JobApplicationsController(db, summarizer.Object, Mock.Of<IAppEmailSender>(), users.Object, NullLogger<JobApplicationsController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
}
}
@@ -27,7 +27,7 @@ public sealed class JobApplicationsControllerTests
Assert.NotNull(type);
var ctor = type!.GetConstructors().Single();
var parameters = ctor.GetParameters().Select(x => x.Name).ToArray();
var parameters = ctor.GetParameters().Select(x => x.Name).Where(x => x is not null).Select(x => x!).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Contains("coverLetterText", parameters);
Assert.Contains("notes", parameters);
}
@@ -3,6 +3,7 @@ using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -77,26 +78,11 @@ public sealed class JobApplicationsEndpointBehaviorTests
private static Mock<Microsoft.AspNetCore.Identity.UserManager<ApplicationUser>> CreateUserManager()
{
var store = new Mock<Microsoft.AspNetCore.Identity.IUserStore<ApplicationUser>>();
return new Mock<Microsoft.AspNetCore.Identity.UserManager<ApplicationUser>>(
store.Object,
null!,
null!,
null!,
null!,
null!,
null!,
null!,
null!);
return TestHostFactory.CreateUserManager();
}
private static JobTrackerContext CreateDb()
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(x => x.UserId).Returns("user-1");
return new JobTrackerContext(options, currentUser.Object);
return TestHostFactory.CreateInMemoryDb();
}
}
@@ -3,12 +3,11 @@ using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
@@ -122,27 +121,11 @@ public sealed class JobApplicationsFollowUpDraftTests
private static JobTrackerContext CreateDb()
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns("user-1");
return new JobTrackerContext(options, currentUser.Object);
return TestHostFactory.CreateInMemoryDb();
}
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object,
Options.Create(new IdentityOptions()),
new PasswordHasher<ApplicationUser>(),
Array.Empty<IUserValidator<ApplicationUser>>(),
Array.Empty<IPasswordValidator<ApplicationUser>>(),
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
null!,
new NullLogger<UserManager<ApplicationUser>>()
);
return TestHostFactory.CreateUserManager();
}
}
@@ -3,6 +3,7 @@ using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -53,26 +54,11 @@ public sealed class JobApplicationsMariaDraftTests
private static Mock<Microsoft.AspNetCore.Identity.UserManager<ApplicationUser>> CreateUserManager()
{
var store = new Mock<Microsoft.AspNetCore.Identity.IUserStore<ApplicationUser>>();
return new Mock<Microsoft.AspNetCore.Identity.UserManager<ApplicationUser>>(
store.Object,
null!,
null!,
null!,
null!,
null!,
null!,
null!,
null!);
return TestHostFactory.CreateUserManager();
}
private static JobTrackerContext CreateDb()
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(x => x.UserId).Returns("user-1");
return new JobTrackerContext(options, currentUser.Object);
return TestHostFactory.CreateInMemoryDb();
}
}
@@ -3,12 +3,12 @@ using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
@@ -126,27 +126,11 @@ public sealed class JobApplicationsWorkflowSignalsTests
private static JobTrackerContext CreateDb()
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns("user-1");
return new JobTrackerContext(options, currentUser.Object);
return TestHostFactory.CreateInMemoryDb();
}
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object,
Options.Create(new IdentityOptions()),
new PasswordHasher<ApplicationUser>(),
Array.Empty<IUserValidator<ApplicationUser>>(),
Array.Empty<IPasswordValidator<ApplicationUser>>(),
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
null!,
new NullLogger<UserManager<ApplicationUser>>()
);
return TestHostFactory.CreateUserManager();
}
}
@@ -0,0 +1,98 @@
using System.Net;
using System.Net.Http;
using JobTrackerApi.Services.JobImport;
using JobTrackerApi.Services.JobImport.Translation;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class JobImportServiceTests
{
[Fact]
public async Task Preview_rejects_hostname_that_resolves_to_loopback()
{
var resolver = new Mock<IHostAddressResolver>();
resolver
.Setup(x => x.ResolveAsync("127.0.0.1.nip.io", It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { IPAddress.Loopback });
var service = CreateService(resolver.Object);
var result = await service.PreviewAsync("http://127.0.0.1.nip.io:5202/api/auth/config", CancellationToken.None);
Assert.False(result.Success);
Assert.Equal("none", result.Parser);
Assert.Equal("Local or private network URLs are not allowed.", result.Error);
}
[Fact]
public async Task Preview_rejects_hostname_that_resolves_to_private_ip()
{
var resolver = new Mock<IHostAddressResolver>();
resolver
.Setup(x => x.ResolveAsync("internal.example.test", It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { IPAddress.Parse("10.10.1.5") });
var service = CreateService(resolver.Object);
var result = await service.PreviewAsync("https://internal.example.test/job/123", CancellationToken.None);
Assert.False(result.Success);
Assert.Equal("Local or private network URLs are not allowed.", result.Error);
}
[Fact]
public async Task Preview_allows_public_hostname_resolution_and_fetches_html()
{
var resolver = new Mock<IHostAddressResolver>();
resolver
.Setup(x => x.ResolveAsync("example.com", It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { IPAddress.Parse("93.184.216.34") });
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("<html><body>no schema</body></html>")
});
var service = CreateService(resolver.Object, handler);
var result = await service.PreviewAsync("https://example.com/job", CancellationToken.None);
Assert.False(result.Success);
Assert.Equal("universal", result.Parser);
Assert.Equal("No JobPosting schema found.", result.Error);
}
private static JobImportService CreateService(IHostAddressResolver resolver, HttpMessageHandler? handler = null)
{
handler ??= new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("<html></html>")
});
var httpClient = new HttpClient(handler, disposeHandler: true);
var factory = new Mock<IHttpClientFactory>();
factory.Setup(x => x.CreateClient("jobimport")).Returns(httpClient);
return new JobImportService(
factory.Object,
new UniversalJobParser(),
Array.Empty<IJobSitePlugin>(),
new NoOpTranslationService(),
resolver);
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(_handler(request));
}
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
@@ -14,14 +14,12 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.14" />
<PackageReference Include="xunit.abstractions" Version="2.0.3" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\JobTrackerApi\JobTrackerApi.csproj" />
<ProjectReference Include="..\JobTrackerBackend\JobTrackerBackend.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,52 @@
using System.Security.Claims;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class LocalAuthIdentityTests
{
[Fact]
public void GetRequiredUserId_returns_null_when_subject_claim_is_missing()
{
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Email, "ghost@example.com")
}, "local"));
var userId = LocalAuthIdentity.GetRequiredUserId(principal);
Assert.Null(userId);
}
[Fact]
public void GetRequiredUserId_returns_nameidentifier_when_present()
{
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "user-123")
}, "local"));
var userId = LocalAuthIdentity.GetRequiredUserId(principal);
Assert.Equal("user-123", userId);
}
[Fact]
public async Task Owner_scoped_query_filters_fail_closed_when_current_user_is_missing()
{
await using var db = TestHostFactory.CreateInMemoryDb(null);
db.Companies.Add(new Company { Name = "Secret Co", OwnerUserId = "user-1" });
db.JobApplications.Add(new JobApplication { JobTitle = "Secret Job", Status = "Applied", OwnerUserId = "user-1" });
db.UserRuleSettings.Add(new UserRuleSettings { OwnerUserId = "user-1", AppliedFollowUpDays = 5 });
await db.SaveChangesAsync();
Assert.Empty(await db.Companies.ToListAsync());
Assert.Empty(await db.JobApplications.ToListAsync());
Assert.Empty(await db.UserRuleSettings.ToListAsync());
}
}
+625 -24
View File
@@ -1,18 +1,18 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
@@ -135,6 +135,7 @@ public sealed class ProfileCvControllerTests
var user = new ApplicationUser { Id = "user-1", CurrentCvProfileVersion = 1 };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
@@ -177,7 +178,12 @@ public sealed class ProfileCvControllerTests
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var result = await controller.Reprocess();
Assert.IsType<OkObjectResult>(result);
var accepted = Assert.IsType<AcceptedResult>(result);
var queuedRun = await db.CvExtractionRuns.SingleAsync();
Assert.Equal("queued", queuedRun.Status);
await controller.ProcessQueuedRunAsync(queuedRun.Id, CancellationToken.None);
var run = await db.CvExtractionRuns.SingleAsync();
Assert.Equal("reprocess", run.Trigger);
Assert.Equal("applied", run.Status);
@@ -277,10 +283,59 @@ public sealed class ProfileCvControllerTests
Assert.Contains(structured.Sections, section => section.Name == "Education");
}
[Fact]
public async Task Upload_uses_ai_normalizer_fallback_when_flattened_text_stays_low_structure()
{
var rawExtraction = "connor.babbington@cesnimda.co.uk cesnimda.co.uk +47 41 33 44 70 E X P E R I E N C E S Y S T E M D E V E L O P E R 2015 - 2023 Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript. + Warwickshire County Council, UK";
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiTextExtractionResult(rawExtraction, false, "application/pdf", 1, rawExtraction.Length, "Resume.en.pdf"));
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Reconstruct this CV text extracted from a PDF", StringComparison.Ordinal)), rawExtraction, 2800, 900))
.ReturnsAsync(string.Empty);
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
var normalizer = new Mock<ICvAiNormalizer>();
normalizer
.Setup(x => x.NormalizeAsync(It.Is<string>(text => text.Contains("Warwickshire County Council", StringComparison.Ordinal)), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CvNormalizationResult(
0.91,
"Recovered structured sections from flattened OCR text",
"# Contact\nConnor Babbington\nconnor.babbington@cesnimda.co.uk\n+47 41 33 44 70\ncesnimda.co.uk\n\n# Professional Summary\nMid-level system developer with eight years of experience in UK local government.\n\n# Work Experience\nSystem Developer\nWarwickshire County Council, UK\n2015 - 2023\n- Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript.\n\n# Skills\nC#\nPython\nRuby on Rails\nSQL\nJavaScript"));
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths, null, normalizer.Object);
var bytes = Encoding.UTF8.GetBytes("fake pdf bytes");
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "Resume.en.pdf")
{
Headers = new HeaderDictionary(),
ContentType = "application/pdf"
};
var result = await controller.Upload(file);
Assert.IsType<OkObjectResult>(result);
normalizer.Verify(x => x.NormalizeAsync(It.Is<string>(text => text.Contains("Warwickshire County Council", StringComparison.Ordinal)), It.IsAny<CancellationToken>()), Times.Once);
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("Connor Babbington", structured.Contact.FullName);
Assert.Contains("# Skills", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Warwickshire County Council", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Upload_populates_structured_fields_from_flattened_cv_when_ai_json_is_invalid()
{
var rawExtraction = "connor.babbington@cesnimda.co.uk cesnimda.co.uk +47 41 33 44 70 E D U C A T I O N E X T E N D E D D I P L O M A N V Q L E V E L 3 I N I C T 2012 - 2015 F O L L O W A B O U T M E Mid-level system developer with eight years of experience in UK local government, with expertise in full-stack development, backend, frontend and server administration. I N T E R E S T S I am interested in PC and board games, as well as cooking and learning new skills. E X P E R I E N C E S Y S T E M D E V E L O P E R 2015 - 2023 Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript. + Warwickshire County Council, UK C O N T A C T Native English speaker, Norwegian level A2/B1.";
var rawExtraction = "connor.babbington@cesnimda.co.uk cesnimda.co.uk +47 41 33 44 70 E D U C A T I O N E X T E N D E D D I P L O M A N V Q L E V E L 3 I N I C T 2012 - 2015 F O L L O W A B O U T M E Mid-level system developer with eight years of experience in UK local government, with expertise in full-stack development, backend, frontend and server administration. I N T E R E S T S I am interested in PC and board games, as well as cooking and learning new skills. E X P E R I E N C E S Y S T E M D E V E L O P E R 2015 - 2023 Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript. + Warwickshire County Council, UK C O N T A C T Native English speaker, Norwegian level A2/B1, C#, SQL, and public speaking.";
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
@@ -320,9 +375,219 @@ public sealed class ProfileCvControllerTests
Assert.Contains(structured.Interests, item => item.Contains("board games", StringComparison.OrdinalIgnoreCase) || item.Contains("cooking", StringComparison.OrdinalIgnoreCase));
Assert.Contains(structured.Languages, item => item.Name != null && item.Name.Equals("English", StringComparison.OrdinalIgnoreCase));
Assert.Contains(structured.Languages, item => item.Name != null && item.Name.StartsWith("Norwegian", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(structured.Languages, item => item.Name != null && item.Name.Equals("C#", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(structured.Languages, item => item.Name != null && item.Name.Equals("SQL", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(structured.Languages, item => item.Name != null && item.Name.Contains("public speaking", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(structured.Sections, section => section.Name == "General");
}
[Fact]
public void Structured_cv_normalization_keeps_human_languages_and_drops_skill_noise()
{
var structured = StructuredCvProfileJson.Deserialize("""
{
"version": "1",
"contact": {},
"summary": [],
"jobs": [],
"education": [],
"skills": [],
"languages": [
{ "name": "English", "level": "Native" },
{ "name": "Native Norwegian speaker", "level": null },
{ "name": "French", "level": null },
{ "name": "C#", "level": "Advanced" },
{ "name": "Leadership", "level": null }
],
"interests": [],
"otherSections": []
}
""");
Assert.Collection(
structured.Languages.OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase),
first =>
{
Assert.Equal("English", first.Name);
Assert.Equal("Native", first.Level);
},
second =>
{
Assert.Equal("Norwegian", second.Name);
Assert.Equal("Native", second.Level);
});
}
[Fact]
public void Structured_cv_normalization_separates_job_title_company_and_tasks()
{
var structured = StructuredCvProfileJson.Deserialize("""
{
"version": "1",
"contact": {},
"summary": [],
"jobs": [
{
"title": "Acme Ltd",
"company": "Senior Backend Developer",
"location": "Oslo",
"start": "2022",
"end": "2024",
"isCurrent": false,
"bullets": [
"Senior Backend Developer",
"Acme Ltd",
"2022 - 2024",
"Built API integrations for recruiter workflows and reduced manual follow-up churn."
],
"skills": [".NET", "SQL"]
},
{
"title": "Lead Engineer at Northwind Council",
"company": null,
"location": "Remote",
"start": "2020",
"end": "Present",
"isCurrent": true,
"bullets": [
"Led platform delivery across case-management and reporting surfaces.",
"Skills: C#, SQL"
],
"skills": ["C#", "SQL"]
}
],
"education": [],
"skills": [],
"languages": [],
"interests": [],
"otherSections": []
}
""");
Assert.Collection(
structured.Jobs,
first =>
{
Assert.Equal("Senior Backend Developer", first.Title);
Assert.Equal("Acme Ltd", first.Company);
Assert.Equal(new[] { "Built API integrations for recruiter workflows and reduced manual follow-up churn." }, first.Bullets);
},
second =>
{
Assert.Equal("Lead Engineer", second.Title);
Assert.Equal("Northwind Council", second.Company);
Assert.Equal(new[] { "Led platform delivery across case-management and reporting surfaces." }, second.Bullets);
});
}
[Fact]
public void Structured_cv_normalization_hardens_contact_links_locations_and_dates()
{
var structured = StructuredCvProfileJson.Deserialize("""
{
"version": "1",
"contact": {
"location": "Python,Ruby",
"website": "https://cesnimda.co.uk/about",
"linkedin": "linkedin.com/in/demo-user?trk=foo"
},
"summary": [],
"jobs": [
{
"title": "System Developer",
"company": "Warwickshire County Council",
"location": "Warwickshire, England, UK",
"start": "Sept 2023",
"end": "1/1/2024",
"isCurrent": false,
"bullets": ["Built APIs"],
"skills": []
},
{
"title": "Developer",
"company": "Demo Co",
"location": "Remote 123",
"start": "Spring 2024",
"end": "Later",
"isCurrent": false,
"bullets": ["Kept services running"],
"skills": []
},
{
"title": "Developer",
"company": "Demo Co",
"location": "Warwickshire College, UK S A L E S R E P R E S E N T A T I V E",
"start": "2021",
"end": "2022",
"isCurrent": false,
"bullets": ["Managed account handovers"],
"skills": []
}
],
"education": [
{
"qualification": "Warwickshire College",
"institution": "ICT Level 3",
"location": "Warwickshire College, UK S A L E S R E P R E S E N T A T I V E",
"start": "2012",
"end": "2015",
"details": []
}
],
"skills": [],
"languages": [],
"interests": [],
"otherSections": []
}
""");
Assert.Null(structured.Contact.Location);
Assert.Equal("cesnimda.co.uk", structured.Contact.Website);
Assert.Equal("https://www.linkedin.com/in/demo-user", structured.Contact.LinkedIn);
Assert.Equal("Warwickshire, England, UK", structured.Jobs[0].Location);
Assert.Equal("Sept 2023", structured.Jobs[0].Start);
Assert.Equal("1/1/2024", structured.Jobs[0].End);
Assert.Null(structured.Jobs[1].Location);
Assert.Null(structured.Jobs[1].Start);
Assert.Null(structured.Jobs[1].End);
Assert.Equal("Warwickshire College, UK", structured.Jobs[2].Location);
Assert.Equal("ICT Level 3", structured.Education[0].Qualification);
Assert.Equal("Warwickshire College", structured.Education[0].Institution);
Assert.Equal("Warwickshire College, UK", structured.Education[0].Location);
}
[Fact]
public async Task Rewrite_section_can_target_saved_job_context_and_whole_cv()
{
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Harvard template", StringComparison.Ordinal) && instruction.Contains("Senior Backend Engineer", StringComparison.Ordinal)), It.Is<string>(text => text.Contains("Professional Summary", StringComparison.Ordinal)), 1800, 400))
.ReturnsAsync("Professional Summary\nSharper backend platform positioning.");
await using var db = CreateDb();
db.Companies.Add(new Company { Id = 7, Name = "Acme Systems", OwnerUserId = "user-1" });
db.JobApplications.Add(new JobApplication { Id = 42, JobTitle = "Senior Backend Engineer", Description = "Build API integrations and platform workflows.", OwnerUserId = "user-1", CompanyId = 7, Status = "Waiting", DateApplied = DateTime.UtcNow });
await db.SaveChangesAsync();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest
{
Style = "harvard",
JobApplicationId = JsonDocument.Parse("42").RootElement.Clone(),
TemplateId = "harvard",
});
var ok = Assert.IsType<OkObjectResult>(result);
var json = JsonSerializer.Serialize(ok.Value);
Assert.Contains("Sharper backend platform positioning", json);
Assert.Contains("harvard", json, StringComparison.OrdinalIgnoreCase);
Assert.Contains("42", json, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Parse_returns_structured_cv_and_persists_it()
{
@@ -425,6 +690,119 @@ public sealed class ProfileCvControllerTests
Assert.Equal("Connor Babbington", structured.Contact.FullName);
}
[Fact]
public async Task Parse_uses_classifier_fallback_when_plain_text_has_no_real_sections()
{
var source = "Senior Platform Engineer at Atlas Systems\nOslo\n2019 - Present\nBuilt event-driven APIs and migration tooling.\n\nPython\nSQL\nAzure";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), source, 3200, 900))
.ReturnsAsync("not-json");
var classifier = new Mock<ICvAiClassifier>();
classifier
.Setup(x => x.ClassifyBlockAsync(It.Is<string>(block => block.Contains("Atlas Systems", StringComparison.Ordinal)), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CvBlockClassificationResult("Work Experience", 0.93, "job block", "Senior Platform Engineer", "Atlas Systems", "Oslo", "2019", "Present", new List<string> { "Built event-driven APIs and migration tooling." }, null, new List<string> { "Python", "SQL" }));
classifier
.Setup(x => x.ClassifyBlockAsync(It.Is<string>(block => block.Contains("Python", StringComparison.Ordinal)), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CvBlockClassificationResult("Skills", 0.88, "skills block", null, null, null, null, null, new List<string>(), null, new List<string> { "Python", "SQL", "Azure" }));
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
var ok = Assert.IsType<OkObjectResult>(result.Result);
var json = JsonSerializer.Serialize(ok.Value);
Assert.Contains("Senior Platform Engineer", json);
Assert.Contains("Atlas Systems", json);
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
var matchedJob = structured.Jobs.FirstOrDefault(job => job.Title == "Senior Platform Engineer");
Assert.NotNull(matchedJob);
Assert.Contains("Atlas Systems", matchedJob!.Company ?? string.Empty, StringComparison.Ordinal);
Assert.Contains("Python", structured.Skills);
Assert.Contains("SQL", structured.Skills);
Assert.Equal("classifier", structured.Metadata.Fields["jobs[0].title"].Method);
Assert.Equal("block-1", structured.Metadata.Fields["jobs[0].title"].SourceBlockId);
classifier.Verify(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.AtLeastOnce());
}
[Fact]
public async Task Parse_uses_classifier_fallback_for_education_blocks_without_real_sections()
{
var source = "BSc Computer Science\nUniversity of Oslo\nOslo\n2016 - 2019\nGraduated with focus on distributed systems.";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), source, 3200, 900))
.ReturnsAsync("not-json");
var classifier = new Mock<ICvAiClassifier>();
classifier
.Setup(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CvBlockClassificationResult("Education", 0.87, "education block", "BSc Computer Science", "University of Oslo", "Oslo", "2016", "2019", new List<string> { "Graduated with focus on distributed systems." }, null, null));
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
var ok = Assert.IsType<OkObjectResult>(result.Result);
var json = JsonSerializer.Serialize(ok.Value);
Assert.Contains("BSc Computer Science", json);
Assert.Contains("University of Oslo", json);
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Single(structured.Education);
Assert.Equal("BSc Computer Science", structured.Education[0].Qualification);
Assert.Equal("University of Oslo", structured.Education[0].Institution);
Assert.Equal("classifier", structured.Metadata.Fields["education[0].qualification"].Method);
}
[Fact]
public async Task Parse_keeps_general_fallback_when_classifier_returns_nothing()
{
var source = "Independent consultant building internal platforms for public-sector clients across data, delivery, and migration work.";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), source, 3200, 900))
.ReturnsAsync("not-json");
var classifier = new Mock<ICvAiClassifier>();
classifier
.Setup(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CvBlockClassificationResult?)null);
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
var ok = Assert.IsType<OkObjectResult>(result.Result);
var json = JsonSerializer.Serialize(ok.Value);
Assert.Contains("Professional Summary", json);
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Contains(structured.Summary, item => item.Contains("Independent consultant", StringComparison.OrdinalIgnoreCase));
Assert.NotNull(user.ProfileCvStructureJson);
classifier.Verify(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.AtLeastOnce());
}
[Fact]
public async Task Upload_accepts_markdown_cv_and_saves_text()
{
@@ -468,9 +846,248 @@ public sealed class ProfileCvControllerTests
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Contact.FullName);
}
private static ProfileCvController CreateController(UserManager<ApplicationUser> userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths)
[Fact]
public void Normalized_markdown_parse_preserves_real_estate_job_and_language_levels()
{
return new ProfileCvController(userManager, aiService, db, paths)
var normalized = "# Contact\nAvery Cooper\n(415) 223-4344\nhttps://www.linkedin.com/in/avery-cooper/\nhttps://www.realtor.com/realestateagents/avery-copper/\nSan Francisco\n\n# Professional Summary\nDynamic real estate professional with 12 years of experience in residential and commercial property.\n\n# Work Experience\nReal Estate Agent\nEleanor Lane Agency, White Plains\nJuly 2017 - Present\n- Managed all aspects of the sales process from preparation to close, achieving a 25% increase in closed deals compared to previous periods.\n- Successfully negotiated favorable terms for clients in over 50 real estate transactions, consistently securing above-market value.\n\nReal Estate Assistant\nHathaway Properties, New Rochelle\nOctober 2012 - June 2017\n- Managed administrative tasks in a fast-paced real estate office, ensuring smooth daily operations.\n- Supported Realtors and Brokers by coordinating marketing materials, client communications, and office transactions.\n\n# Skills\n- Contract Management\n- Retail Market Analysis\n- Property Valuation\n- Client Relationship Management\n- Digital Marketing\n- Attention to Detail\n\n# Languages\n- English (Native)\n- Spanish - C2";
var actual = ParseNormalizedMarkdown(normalized);
Assert.Equal("Avery Cooper", actual.Contact.FullName);
Assert.Equal("San Francisco", actual.Contact.Location);
Assert.NotEmpty(actual.Jobs);
Assert.Equal("Real Estate Agent", actual.Jobs[0].Title);
Assert.Equal("Eleanor Lane Agency, White Plains", actual.Jobs[0].Company);
Assert.True(actual.Jobs[0].Bullets.Count >= 2);
Assert.Contains("Contract Management", actual.Skills);
Assert.Contains(actual.Languages, item => string.Equals(item.Name, "Spanish", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "C2", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Normalized_markdown_parse_preserves_web_developer_bullets_and_skills()
{
var normalized = "# Contact\nChristoper Morgan\nchristoper.m@gmail.com\n+44 (0)20 7666 8555\n\n# Professional Summary\nSenior Web Developer specializing in front end development. Experienced with all stages of the development cycle for dynamic web projects.\n\n# Work Experience\nWeb Developer\nLuna Web Design, New York\n09/2015 - 05/2019\n- Cooperate with designers to create clean interfaces and simple, intuitive interactions and experiences.\n- Develop project concepts and maintain optimal workflow.\n- Work with senior developer to manage large, complex design projects for corporate clients.\n- Complete detailed programming and development tasks for front end public and internal websites as well as challenging back-end server code.\n- Carry out quality assurance tests to discover errors and optimize usability.\n\n# Skills\n- JavaScript\n- HTML5\n- PHP OOP\n- CSS\n- SQL\n- MySQL\n\n# Languages\n- Spanish - C2\n- Chinese - A1\n- German - A2";
var actual = ParseNormalizedMarkdown(normalized);
Assert.Equal("Christoper Morgan", actual.Contact.FullName);
Assert.NotEmpty(actual.Jobs);
Assert.Equal("Web Developer", actual.Jobs[0].Title);
Assert.Equal("Luna Web Design, New York", actual.Jobs[0].Company);
Assert.True(actual.Jobs[0].Bullets.Count >= 5);
Assert.Contains("JavaScript", actual.Skills);
Assert.Contains("MySQL", actual.Skills);
Assert.Contains(actual.Languages, item => string.Equals(item.Name, "Chinese", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "A1", StringComparison.OrdinalIgnoreCase));
Assert.Contains(actual.Languages, item => string.Equals(item.Name, "German", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "A2", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Parse_uses_forced_ai_normalizer_output_when_enabled()
{
var previous = Environment.GetEnvironmentVariable("CV_FORCE_AI_NORMALIZER");
Environment.SetEnvironmentVariable("CV_FORCE_AI_NORMALIZER", "true");
try
{
var source = "Avery CooperReal Estate Agent\nSan Francisco(415) 223-4344\nDynamic real estate professional with 12 years of experience in residential and commercial property.";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
var normalizer = new Mock<ICvAiNormalizer>();
normalizer
.Setup(x => x.NormalizeAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CvNormalizationResult(
0.88,
"forced test",
"# Contact\nAvery Cooper\n(415) 223-4344\nhttps://www.linkedin.com/in/avery-cooper/\nhttps://www.realtor.com/realestateagents/avery-copper/\nSan Francisco\n\n# Professional Summary\nDynamic real estate professional with 12 years of experience in residential and commercial property.\n\n# Work Experience\nReal Estate Agent\nEleanor Lane Agency, White Plains\nJuly 2017 - Present\n- Managed all aspects of the sales process from preparation to close.\n\n# Skills\n- Contract Management\n- Property Valuation\n\n# Languages\n- English (Native)\n- Spanish - C2"));
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths(), null, normalizer.Object);
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("Avery Cooper", actual.Contact.FullName);
Assert.Equal("San Francisco", actual.Contact.Location);
Assert.NotEmpty(actual.Jobs);
Assert.Contains("Real Estate Agent", actual.Jobs[0].Title ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Contract Management", actual.Skills);
Assert.Contains(actual.Languages, item => string.Equals(item.Name, "Spanish", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "C2", StringComparison.OrdinalIgnoreCase));
}
finally
{
Environment.SetEnvironmentVariable("CV_FORCE_AI_NORMALIZER", previous);
}
}
[Fact]
public async Task Approved_fixture_regression_for_cv_txt_keeps_core_fields_stable()
{
var approvedPath = "/home/pi/cvs/approved-jsons/cv-txt.json";
var rawPath = "/home/pi/cvs/cv.txt";
if (!System.IO.File.Exists(approvedPath) || !System.IO.File.Exists(rawPath)) return;
var approved = StructuredCvProfileJson.Deserialize(await System.IO.File.ReadAllTextAsync(approvedPath));
var rawSource = await System.IO.File.ReadAllTextAsync(rawPath);
var user = new ApplicationUser { Id = "user-1", ProfileCvText = rawSource };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(rawSource));
var ok = Assert.IsType<OkObjectResult>(result.Result);
Assert.NotNull(ok.Value);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal(approved.Contact.FullName, actual.Contact.FullName);
Assert.Equal(approved.Contact.Location, actual.Contact.Location);
Assert.True(actual.Skills.Count >= 2);
}
[Fact]
public async Task Approved_fixture_regression_for_new_resume_docx_keeps_contact_and_role_core_fields_stable()
{
var approvedPath = "/home/pi/cvs/approved-jsons/new-resume-001-docx.json";
if (!System.IO.File.Exists(approvedPath)) return;
var approved = StructuredCvProfileJson.Deserialize(await System.IO.File.ReadAllTextAsync(approvedPath));
var source = "Christoper Morgan\nPhone: +49 800 600 600\nE-Mail: christoper.morgan@gmail.com\nLinkedin: linkedin.com/christopher.morgan\n\nSkill Highlights\nProject management\nStrong decision maker\nComplex problem solver\nCreative design\nInnovative\nService-focused\n\n09/2015 to 05/2019\nWeb Developer\nLuna Web Design, New York\nCooperate with designers to create clean interfaces and simple, intuitive interactions and experiences.\nDevelop project concepts and maintain optimal workflow.\nWork with senior developer to manage large, complex design projects for corporate clients.\nComplete detailed programming and development tasks for front end public and internal websites as well as challenging back-end server code.\nCarry out quality assurance tests to discover errors and optimize usability.\n\n2014 to 2019\nBachelor Of Science: Computer Information Systems\nColumbia University, NY\n\nLanguages\nSpanish C2\nChinese C2\n\nSkills\nJavaScript\nSQL";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal(approved.Contact.FullName, actual.Contact.FullName);
Assert.Equal(approved.Contact.Email, actual.Contact.Email);
Assert.NotEmpty(actual.Jobs);
Assert.Contains("Web Developer", actual.Jobs[0].Title ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("JavaScript", actual.Skills);
Assert.Contains("SQL", actual.Skills);
}
[Fact]
public async Task Approved_fixture_regression_for_coolfreecv_resume_keeps_summary_and_bullets_stable()
{
var approvedPath = "/home/pi/cvs/approved-jsons/coolfreecv-resume-en-03-n-docx.json";
if (!System.IO.File.Exists(approvedPath)) return;
var approved = StructuredCvProfileJson.Deserialize(await System.IO.File.ReadAllTextAsync(approvedPath));
var source = "Christoper Morgan\nchristoper.m@gmail.com\n+44 (0)20 7666 8555\n\nSenior Web Developer specializing in front end development. Experienced with all stages of the development cycle for dynamic web projects. Well-versed in numerous programming languages including HTML5, PHP OOP, JavaScript, CSS, MySQL. Strong background in project management and customer relations.\n\nWeb Developer - 09/2015 to 05/2019\nLuna Web Design, New York\nCooperate with designers to create clean interfaces and simple, intuitive interactions and experiences.\nDevelop project concepts and maintain optimal workflow.\nWork with senior developer to manage large, complex design projects for corporate clients.\nComplete detailed programming and development tasks for front end public and internal websites as well as challenging back-end server code.\nCarry out quality assurance tests to discover errors and optimize usability.\n\nBachelor Of Science: Computer Information Systems - 2014\nColumbia University, NY\n\nSkills\nJavaScript, HTML5, PHP OOP, CSS, SQL, MySQL\nProject management\nStrong decision maker\nComplex problem solver\nCreative design\nInnovative\nService-focused\n\nLanguages\nSpanish C2\nChinese A1\nGerman A2";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal(approved.Contact.FullName, actual.Contact.FullName);
Assert.Equal(approved.Contact.Email, actual.Contact.Email);
Assert.NotEmpty(actual.Summary);
Assert.Contains("Senior Web Developer", actual.Summary[0], StringComparison.OrdinalIgnoreCase);
Assert.NotEmpty(actual.Jobs);
Assert.Contains("Web Developer", actual.Jobs[0].Title ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("JavaScript", actual.Skills);
Assert.Contains("MySQL", actual.Skills);
}
[Fact]
public async Task Deterministic_parse_handles_flat_resume_contact_and_first_job()
{
var source = "Christoper Morgan\nchristoper.m@gmail.com\n+44 (0)20 7666 8555\nSenior Web Developer specializing in front end development. Experienced with all stages of the development cycle for dynamic web projects.\n\nWeb Developer - 09/2015 to 05/2019\nLuna Web Design, New York\nCooperate with designers to create clean interfaces and simple, intuitive interactions and experiences.\nDevelop project concepts and maintain optimal workflow.\nWork with senior developer to manage large, complex design projects for corporate clients.\nComplete detailed programming and development tasks for front end public and internal websites as well as challenging back-end server code.\nCarry out quality assurance tests to discover errors and optimize usability.\n\nSkills\nJavaScript, HTML5, PHP OOP, CSS, SQL, MySQL";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("Christoper Morgan", actual.Contact.FullName);
Assert.Equal("christoper.m@gmail.com", actual.Contact.Email);
Assert.Equal("+44 (0)20 7666 8555", actual.Contact.Phone);
Assert.NotEmpty(actual.Jobs);
Assert.Contains("Web Developer", actual.Jobs[0].Title ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("JavaScript", actual.Skills);
Assert.Contains("SQL", actual.Skills);
}
[Fact]
public async Task Deterministic_parse_handles_real_estate_contact_summary_and_jobs()
{
var source = "Avery Cooper Real Estate Agent\n(415) 223-4344\nSan Francisco\nhttps://www.linkedin.com/in/avery-cooper\nhttps://www.realtor.com/realestateagents/avery-copper/\n\nDynamic real estate professional with 12 years of experience in residential and commercial property. Proven track record in developing strong client relationships, closing over 50 successful deals, and providing exceptional real estate experiences.\n\nReal Estate Agent at Eleanor Lane Agency\nWhite Plains\n2017 - Present\nManaged all aspects of the sales process from preparation to close, achieving a 25% increase in closed deals compared to previous periods.\nSuccessfully negotiated favorable terms for clients in over 50 real estate transactions, consistently securing above-market value.\n\nReal Estate Assistant at Hathaway Properties\nNew Rochelle\n2012 - 2017\nManaged administrative tasks in a fast-paced real estate office, ensuring smooth daily operations.\nSupported Realtors and Brokers by coordinating marketing materials, client communications, and office transactions.\n\nSkills\nContract Management, Retail Market Analysis, Property Valuation, Client Relationship Management, Digital Marketing, Attention to Detail";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
Assert.IsType<OkObjectResult>(result.Result);
var actual = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("Avery Cooper", actual.Contact.FullName);
Assert.Equal("San Francisco", actual.Contact.Location);
Assert.Contains("realtor.com", actual.Contact.Website ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.NotEmpty(actual.Summary);
Assert.True(actual.Jobs.Count >= 2);
Assert.Contains("Contract Management", actual.Skills);
Assert.Contains("Attention to Detail", actual.Skills);
}
private static StructuredCvProfile ParseNormalizedMarkdown(string normalized)
{
var method = typeof(ProfileCvController).GetMethod("BuildStructuredCvFromNormalizedMarkdown", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert.NotNull(method);
var result = method!.Invoke(null, new object[] { normalized });
Assert.NotNull(result);
return StructuredCvProfileJson.Normalize((StructuredCvProfile)result!);
}
private static ProfileCvController CreateController(UserManager<ApplicationUser> userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null)
{
return new ProfileCvController(userManager, aiService, db, paths, null, cvAiClassifier ?? NoOpCvAiClassifier.Instance, cvAiNormalizer ?? NoOpCvAiNormalizer.Instance)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
@@ -478,12 +1095,7 @@ public sealed class ProfileCvControllerTests
private static JobTrackerContext CreateDb(string userId = "user-1")
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(x => x.UserId).Returns(userId);
return new JobTrackerContext(options, currentUser.Object);
return TestHostFactory.CreateInMemoryDb(userId);
}
private static AppPaths CreatePaths()
@@ -506,17 +1118,6 @@ public sealed class ProfileCvControllerTests
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object,
Options.Create(new IdentityOptions()),
new PasswordHasher<ApplicationUser>(),
Array.Empty<IUserValidator<ApplicationUser>>(),
Array.Empty<IPasswordValidator<ApplicationUser>>(),
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
null!,
new NullLogger<UserManager<ApplicationUser>>()
);
return TestHostFactory.CreateUserManager();
}
}
@@ -0,0 +1,50 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
namespace JobTrackerApi.Tests.TestSupport;
public static class TestHostFactory
{
// Keep the EF-backed controller tests on the same minimal setup so they fail for product
// reasons, not because each file drifted into a slightly different fake host configuration.
public static JobTrackerContext CreateInMemoryDb(string? userId = "user-1")
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns(userId);
return new JobTrackerContext(options, currentUser.Object);
}
public static Mock<UserManager<ApplicationUser>> CreateUserManager(ApplicationUser? lookupUser = null)
{
var store = new Mock<IUserStore<ApplicationUser>>();
var manager = new Mock<UserManager<ApplicationUser>>(
store.Object,
Options.Create(new IdentityOptions()),
new PasswordHasher<ApplicationUser>(),
Array.Empty<IUserValidator<ApplicationUser>>(),
Array.Empty<IPasswordValidator<ApplicationUser>>(),
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
null!,
new NullLogger<UserManager<ApplicationUser>>()
);
if (lookupUser is not null)
{
manager
.Setup(x => x.FindByIdAsync(It.IsAny<string>()))
.ReturnsAsync((string id) => lookupUser.Id == id ? lookupUser : null);
}
return manager;
}
}
@@ -35,6 +35,7 @@ public sealed class AdminSystemController : ControllerBase
public sealed record DatabaseStatusDto(string Provider, bool LooksConfigured, bool CanConnect, string? Target, bool UsesFileStorage, string? Warning);
public sealed record RuntimeStatusDto(string Framework, string OSDescription, string ProcessArchitecture, string? MachineName);
public sealed record AuthStatusDto(bool Required, bool HasJwtKey, bool GoogleConfigured, bool GmailConfigured);
public sealed record CvBenchmarkStatusDto(string? IndexJson, string? ReportMarkdown, string RootPath, DateTimeOffset? LastUpdatedAtUtc);
public sealed record SystemStatusDto(
string Environment,
string ContentRoot,
@@ -64,6 +65,50 @@ public sealed class AdminSystemController : ControllerBase
return trimmed;
}
private EmailSettingsSnapshot BuildFallbackEmailSettingsSnapshot()
{
var host = (_cfg["Email:SmtpHost"] ?? string.Empty).Trim();
var user = (_cfg["Email:SmtpUser"] ?? string.Empty).Trim();
var password = (_cfg["Email:SmtpPassword"] ?? string.Empty).Trim();
var from = (_cfg["Email:From"] ?? user).Trim();
var fromName = (_cfg["Email:FromName"] ?? "Jobbjakt").Trim();
var port = _cfg.GetValue("Email:SmtpPort", 587);
if (port <= 0) port = 587;
var enableSsl = _cfg.GetValue("Email:SmtpEnableSsl", true);
var timeoutMs = _cfg.GetValue("Email:SmtpTimeoutMs", 15000);
if (timeoutMs <= 0) timeoutMs = 15000;
var enabled = _cfg.GetValue("Email:Enabled", false);
return new EmailSettingsSnapshot(
Enabled: enabled,
Host: host,
Port: port,
User: user,
Password: password,
From: from,
FromName: fromName,
EnableSsl: enableSsl,
TimeoutMs: timeoutMs,
UsesOverrides: false,
HasPassword: !string.IsNullOrWhiteSpace(password));
}
private EmailSettingsAdminDto BuildFallbackEmailSettings(string? reason = null)
{
var snapshot = BuildFallbackEmailSettingsSnapshot();
return new EmailSettingsAdminDto(
Enabled: snapshot.Enabled,
Host: snapshot.Host,
Port: snapshot.Port,
User: snapshot.User,
From: snapshot.From,
FromName: string.IsNullOrWhiteSpace(reason) ? snapshot.FromName : $"{snapshot.FromName} (fallback)",
EnableSsl: snapshot.EnableSsl,
TimeoutMs: snapshot.TimeoutMs,
UsesOverrides: snapshot.UsesOverrides,
HasPassword: snapshot.HasPassword);
}
[HttpPost("ai/probe")]
[HttpPost("summarizer/probe")]
public async Task<IActionResult> RunSummarizerProbe(CancellationToken cancellationToken)
@@ -75,7 +120,14 @@ public sealed class AdminSystemController : ControllerBase
[HttpGet("email-settings")]
public async Task<ActionResult<EmailSettingsAdminDto>> GetEmailSettings(CancellationToken cancellationToken)
{
return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken));
try
{
return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken));
}
catch (Exception ex)
{
return Ok(BuildFallbackEmailSettings(ex.Message));
}
}
[HttpPut("email-settings")]
@@ -86,6 +138,22 @@ public sealed class AdminSystemController : ControllerBase
return Ok(await _emailSettings.UpdateAsync(request, cancellationToken));
}
[HttpGet("cv-benchmark")]
public async Task<ActionResult<CvBenchmarkStatusDto>> GetCvBenchmarkStatus(CancellationToken cancellationToken)
{
var indexPath = Path.Combine(_paths.CvBenchmarksRoot, "index.json");
var reportPath = Path.Combine(_paths.CvBenchmarksRoot, "report.md");
var indexJson = System.IO.File.Exists(indexPath) ? await System.IO.File.ReadAllTextAsync(indexPath, cancellationToken) : null;
var reportMarkdown = System.IO.File.Exists(reportPath) ? await System.IO.File.ReadAllTextAsync(reportPath, cancellationToken) : null;
var lastUpdated = new[]
{
System.IO.File.Exists(indexPath) ? System.IO.File.GetLastWriteTimeUtc(indexPath) : (DateTime?)null,
System.IO.File.Exists(reportPath) ? System.IO.File.GetLastWriteTimeUtc(reportPath) : (DateTime?)null,
}.Where(value => value.HasValue).Select(value => value!.Value).DefaultIfEmpty().Max();
return Ok(new CvBenchmarkStatusDto(indexJson, reportMarkdown, _paths.CvBenchmarksRoot, lastUpdated == default ? null : new DateTimeOffset(DateTime.SpecifyKind(lastUpdated, DateTimeKind.Utc))));
}
[HttpGet]
public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken)
{
@@ -124,6 +192,14 @@ public sealed class AdminSystemController : ControllerBase
GpuName: null,
OcrAvailable: false,
OcrLanguages: null,
OllamaConfigured: null,
OllamaReachable: null,
OllamaModel: null,
OllamaModelAvailable: null,
OllamaVersion: null,
OllamaInstalledModels: Array.Empty<string>(),
OllamaLoadedModels: Array.Empty<string>(),
OllamaLoadedCount: 0,
HealthLatencyMs: null,
ProbeLatencyMs: null,
LastProbeAt: null,
@@ -207,7 +283,15 @@ public sealed class AdminSystemController : ControllerBase
var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim())
&& !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim());
var emailSettings = await _emailSettings.GetSnapshotAsync(cancellationToken);
EmailSettingsSnapshot emailSettings;
try
{
emailSettings = await _emailSettings.GetSnapshotAsync(cancellationToken);
}
catch (Exception)
{
emailSettings = BuildFallbackEmailSettingsSnapshot();
}
return Ok(new SystemStatusDto(
Environment: _env.EnvironmentName,
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
@@ -9,6 +10,7 @@ namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/attachments")]
[Authorize(AuthenticationSchemes = "local")]
public class AttachmentsController : ControllerBase
{
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB per file keeps local storage use predictable.
+116 -23
View File
@@ -5,6 +5,7 @@ using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
@@ -47,9 +48,9 @@ public sealed class AuthController : ControllerBase
});
}
public sealed record LoginRequest(string Email, string Password);
public sealed record RegisterRequest(string Email, string Password);
public sealed record AuthResult(string AccessToken, string TokenType);
public sealed record LoginRequest(string Email, string Password, bool RememberMe = true);
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true);
public sealed record AuthSessionResult(bool Authenticated, string Provider);
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MeResult(
string Provider,
@@ -64,12 +65,18 @@ public sealed class AuthController : ControllerBase
string? AvatarImageDataUrl,
IList<string> Roles,
GoogleLinkDto? GoogleLink);
private const int MaxAvatarBytes = 1_000_000;
private static readonly HashSet<string> AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".webp"
};
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson);
public sealed record GoogleTokenRequest(string Token);
public sealed record GoogleTokenRequest(string Token, bool RememberMe = true);
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<AuthResult>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
[EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
{
var email = (request.Email ?? string.Empty).Trim();
var password = request.Password ?? string.Empty;
@@ -83,13 +90,14 @@ public sealed class AuthController : ControllerBase
var ok = await _users.CheckPasswordAsync(user, password);
if (!ok) return Unauthorized();
var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
return Ok(new AuthResult(token, "Bearer"));
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "local"));
}
[HttpPost("register")]
[AllowAnonymous]
public async Task<ActionResult<AuthResult>> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
[EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
{
var allow = _cfg.GetValue("Auth:AllowRegistration", false);
if (!allow) return StatusCode(403, "Registration is disabled.");
@@ -110,13 +118,14 @@ public sealed class AuthController : ControllerBase
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
}
var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
return Ok(new AuthResult(token, "Bearer"));
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "local"));
}
[HttpPost("google/exchange")]
[AllowAnonymous]
public async Task<ActionResult<AuthResult>> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
[EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
{
var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Google token is required.");
@@ -160,8 +169,23 @@ public sealed class AuthController : ControllerBase
await _users.UpdateAsync(user);
}
var appToken = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
return Ok(new AuthResult(appToken, "Bearer"));
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "google"));
}
[HttpPost("logout")]
public IActionResult Logout()
{
ClearSessionCookies();
return NoContent();
}
[HttpGet("csrf")]
[AllowAnonymous]
public IActionResult EnsureCsrfCookie()
{
EnsureCsrfCookie(false);
return NoContent();
}
[HttpGet("me")]
@@ -300,7 +324,7 @@ public sealed class AuthController : ControllerBase
[HttpPost("avatar")]
[Authorize(AuthenticationSchemes = "local")]
[RequestSizeLimit(5_000_000)]
[RequestSizeLimit(MaxAvatarBytes)]
public async Task<IActionResult> UploadAvatar([FromForm] IFormFile? file)
{
var user = await _users.GetUserAsync(User);
@@ -314,24 +338,30 @@ public sealed class AuthController : ControllerBase
return BadRequest("Image file is required.");
}
if (!string.Equals(file.ContentType, "image/png", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(file.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(file.ContentType, "image/webp", StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Only PNG, JPEG, or WebP images are supported.");
}
if (file.Length > 5_000_000)
if (file.Length > MaxAvatarBytes)
{
return BadRequest("Avatar image is too large.");
}
var extension = Path.GetExtension(file.FileName ?? string.Empty);
if (!AllowedAvatarExtensions.Contains(extension))
{
return BadRequest("Only PNG, JPEG, or WebP images are supported.");
}
await using var stream = file.OpenReadStream();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
var bytes = memory.ToArray();
var detectedContentType = DetectAvatarContentType(bytes);
if (detectedContentType is null)
{
return BadRequest("Only PNG, JPEG, or WebP images are supported.");
}
var base64 = Convert.ToBase64String(bytes);
user.AvatarImageDataUrl = $"data:{file.ContentType};base64,{base64}";
user.AvatarImageDataUrl = $"data:{detectedContentType};base64,{base64}";
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
@@ -388,6 +418,7 @@ public sealed class AuthController : ControllerBase
[HttpPost("request-password-reset")]
[AllowAnonymous]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> RequestPasswordReset([FromBody] RequestPasswordResetRequest request, CancellationToken cancellationToken)
{
var email = (request.Email ?? string.Empty).Trim();
@@ -431,6 +462,7 @@ public sealed class AuthController : ControllerBase
[HttpPost("reset-password")]
[AllowAnonymous]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
{
var email = (request.Email ?? string.Empty).Trim();
@@ -456,6 +488,67 @@ public sealed class AuthController : ControllerBase
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail);
}
private async Task SignInWithAppSessionAsync(ApplicationUser user, bool rememberMe, CancellationToken cancellationToken)
{
var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
Response.Cookies.Append(AuthSessionOptions.SessionCookieName, token, AuthSessionOptions.BuildSessionCookie(rememberMe, secure));
EnsureCsrfCookie(rememberMe, secure);
}
private void EnsureCsrfCookie(bool persistent, bool? secureOverride = null)
{
var secure = secureOverride ?? Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
var csrf = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
Response.Cookies.Append(AuthSessionOptions.CsrfCookieName, csrf, AuthSessionOptions.BuildCsrfCookie(persistent, secure));
}
private void ClearSessionCookies()
{
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(secure));
Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(secure));
}
private static string? DetectAvatarContentType(byte[] bytes)
{
if (bytes.Length >= 8
&& bytes[0] == 0x89
&& bytes[1] == 0x50
&& bytes[2] == 0x4E
&& bytes[3] == 0x47
&& bytes[4] == 0x0D
&& bytes[5] == 0x0A
&& bytes[6] == 0x1A
&& bytes[7] == 0x0A)
{
return "image/png";
}
if (bytes.Length >= 3
&& bytes[0] == 0xFF
&& bytes[1] == 0xD8
&& bytes[2] == 0xFF)
{
return "image/jpeg";
}
if (bytes.Length >= 12
&& bytes[0] == 0x52
&& bytes[1] == 0x49
&& bytes[2] == 0x46
&& bytes[3] == 0x46
&& bytes[8] == 0x57
&& bytes[9] == 0x45
&& bytes[10] == 0x42
&& bytes[11] == 0x50)
{
return "image/webp";
}
return null;
}
private static string? TrimOrNull(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
@@ -1,5 +1,6 @@
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -9,6 +10,7 @@ namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/backup")]
[Authorize(AuthenticationSchemes = "local")]
public class BackupController : ControllerBase
{
private readonly JobTrackerContext _db;
@@ -1,11 +1,17 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/client-errors")]
[RequestSizeLimit(32 * 1024)]
public class ClientErrorsController : ControllerBase
{
private const int MaxFieldLength = 512;
private const int MaxStackSummaryLength = 1024;
private readonly ILogger<ClientErrorsController> _logger;
public ClientErrorsController(ILogger<ClientErrorsController> logger)
@@ -26,19 +32,69 @@ namespace JobTrackerApi.Controllers
[HttpPost]
public IActionResult Report([FromBody] ClientErrorReport report)
{
var errorId = Normalize(report.ErrorId, 128) ?? "unknown";
var at = Normalize(report.At, 128) ?? "unknown";
var url = Normalize(report.Url, MaxFieldLength) ?? "unknown";
var userAgent = Normalize(report.UserAgent, MaxFieldLength) ?? "unknown";
var message = Normalize(report.Message, MaxFieldLength) ?? "unknown";
var stackHash = Hash(report.Stack);
var componentStackHash = Hash(report.ComponentStack);
var stackPreview = SummarizeStack(report.Stack);
var componentPreview = SummarizeStack(report.ComponentStack);
_logger.LogError(
"ClientError {ErrorId} at {At} url={Url} ua={UserAgent} msg={Message}\n{Stack}\n{ComponentStack}",
report.ErrorId ?? "unknown",
report.At ?? "unknown",
report.Url ?? "unknown",
report.UserAgent ?? "unknown",
report.Message ?? "unknown",
report.Stack ?? "",
report.ComponentStack ?? ""
"ClientError {ErrorId} at {At} url={Url} ua={UserAgent} msg={Message} stackHash={StackHash} componentHash={ComponentStackHash} stackPreview={StackPreview} componentPreview={ComponentPreview}",
errorId,
at,
url,
userAgent,
message,
stackHash,
componentStackHash,
stackPreview,
componentPreview
);
return NoContent();
}
internal static string? Normalize(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var normalized = value.Trim().Replace("\r", " ").Replace("\n", " ");
if (normalized.Length <= maxLength)
{
return normalized;
}
return normalized[..maxLength];
}
internal static string? SummarizeStack(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var lines = value
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(line => line.Replace("\r", string.Empty).Trim())
.Where(line => line.Length > 0)
.Take(2)
.ToArray();
if (lines.Length == 0) return null;
var summary = string.Join(" | ", lines);
return summary.Length <= MaxStackSummaryLength ? summary : summary[..MaxStackSummaryLength];
}
internal static string? Hash(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
}
@@ -25,6 +25,84 @@ namespace JobTrackerApi.Controllers
.FirstOrDefaultAsync(c => c.Id == correspondenceId, cancellationToken);
}
public sealed record CorrespondenceInboxItemDto(
int Id,
int JobApplicationId,
string? CompanyName,
string? JobTitle,
string From,
string? Direction,
string? Subject,
string? Channel,
DateTime Date,
string ContentPreview,
string? ExternalThreadId,
string? ExternalFrom,
string? ExternalTo,
int LabelCount,
int AttachmentCount);
[HttpGet]
public async Task<ActionResult<List<CorrespondenceInboxItemDto>>> GetInbox(
[FromQuery] string? q,
[FromQuery] string? direction,
[FromQuery] string? linkState,
CancellationToken cancellationToken)
{
var query = _db.Correspondences
.Include(c => c.JobApplication)
.ThenInclude(j => j.Company)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(q))
{
var needle = q.Trim();
query = query.Where(c =>
(c.Subject != null && EF.Functions.Like(c.Subject, $"%{needle}%")) ||
EF.Functions.Like(c.Content, $"%{needle}%") ||
(c.ExternalFrom != null && EF.Functions.Like(c.ExternalFrom, $"%{needle}%")) ||
(c.JobApplication.JobTitle != null && EF.Functions.Like(c.JobApplication.JobTitle, $"%{needle}%")) ||
(c.JobApplication.Company.Name != null && EF.Functions.Like(c.JobApplication.Company.Name, $"%{needle}%")));
}
if (!string.IsNullOrWhiteSpace(direction) && !string.Equals(direction, "all", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(c => c.Direction == direction);
}
if (string.Equals(linkState, "linked", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(c => c.ExternalThreadId != null);
}
else if (string.Equals(linkState, "manual", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(c => c.ExternalThreadId == null);
}
var items = await query
.OrderByDescending(c => c.Date)
.Take(200)
.Select(c => new CorrespondenceInboxItemDto(
c.Id,
c.JobApplicationId,
c.JobApplication.Company != null ? c.JobApplication.Company.Name : null,
c.JobApplication.JobTitle,
c.From,
c.Direction,
c.Subject,
c.Channel,
c.Date,
c.Content.Length <= 220 ? c.Content : c.Content.Substring(0, 220),
c.ExternalThreadId,
c.ExternalFrom,
c.ExternalTo,
c.ExternalLabelsJson != null ? 1 : 0,
c.AttachmentMetadataJson != null ? 1 : 0))
.ToListAsync(cancellationToken);
return Ok(items);
}
// GET all messages for a job
[HttpGet("{jobId:int}")]
public async Task<ActionResult<List<Correspondence>>> GetForJob([FromRoute] int jobId, CancellationToken cancellationToken)
@@ -48,10 +126,13 @@ namespace JobTrackerApi.Controllers
string? Subject,
string? Channel,
DateTime? Date,
string? Direction,
string? ExternalMessageId,
string? ExternalThreadId,
string? ExternalFrom,
string? ExternalTo
string? ExternalTo,
string? ExternalLabelsJson,
string? AttachmentMetadataJson
);
// POST new message
@@ -71,10 +152,13 @@ namespace JobTrackerApi.Controllers
From = request.From.Trim(),
Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(),
Channel = string.IsNullOrWhiteSpace(request.Channel) ? null : request.Channel.Trim(),
Direction = string.IsNullOrWhiteSpace(request.Direction) ? null : request.Direction.Trim(),
ExternalMessageId = string.IsNullOrWhiteSpace(request.ExternalMessageId) ? null : request.ExternalMessageId.Trim(),
ExternalThreadId = string.IsNullOrWhiteSpace(request.ExternalThreadId) ? null : request.ExternalThreadId.Trim(),
ExternalFrom = string.IsNullOrWhiteSpace(request.ExternalFrom) ? null : request.ExternalFrom.Trim(),
ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(),
ExternalLabelsJson = string.IsNullOrWhiteSpace(request.ExternalLabelsJson) ? null : request.ExternalLabelsJson.Trim(),
AttachmentMetadataJson = string.IsNullOrWhiteSpace(request.AttachmentMetadataJson) ? null : request.AttachmentMetadataJson.Trim(),
Content = request.Content,
Date = request.Date ?? DateTime.Now,
};
@@ -1,4 +1,5 @@
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
@@ -7,6 +8,7 @@ namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/export")]
[Authorize(AuthenticationSchemes = "local")]
public class ExportController : ControllerBase
{
private readonly JobTrackerContext _db;
+688 -171
View File
@@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
@@ -14,12 +15,14 @@ namespace JobTrackerApi.Controllers;
public sealed class GmailController : ControllerBase
{
private readonly IGmailOAuthService _gmail;
private readonly IGmailJobMatchingService _matching;
private readonly JobTrackerContext _db;
private readonly IConfiguration _cfg;
public GmailController(IGmailOAuthService gmail, JobTrackerContext db, IConfiguration cfg)
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg)
{
_gmail = gmail;
_matching = matching;
_db = db;
_cfg = cfg;
}
@@ -68,18 +71,49 @@ public sealed class GmailController : ControllerBase
int CandidateThreadCount,
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
public sealed record GmailConnectionStatusDto(
bool Connected,
string? GmailAddress,
DateTimeOffset? ConnectedAt,
DateTimeOffset? LastSyncedAt,
DateTimeOffset? LastSyncAttemptedAt,
DateTimeOffset? LastSyncSucceededAt,
string? LastSyncMode,
string? LastSyncSource,
string? LastSyncStatus,
string? LastSyncError);
[HttpGet("status")]
public async Task<IActionResult> Status(CancellationToken cancellationToken)
public async Task<ActionResult<GmailConnectionStatusDto>> Status(CancellationToken cancellationToken)
{
var ownerUserId = GetRequiredOwnerUserId();
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
return Ok(new
{
connected = connection is not null,
gmailAddress = connection?.GmailAddress,
connectedAt = connection?.ConnectedAt,
lastSyncedAt = connection?.LastSyncedAt,
});
return Ok(new GmailConnectionStatusDto(
connection is not null,
connection?.GmailAddress,
connection?.ConnectedAt,
connection?.LastSyncedAt,
connection?.LastSyncAttemptedAt,
connection?.LastSyncSucceededAt,
connection?.LastSyncMode,
connection?.LastSyncSource,
connection?.LastSyncStatus,
connection?.LastSyncError));
}
[HttpGet("connect-url")]
@@ -103,23 +137,25 @@ public sealed class GmailController : ControllerBase
var job = await _db.JobApplications
.Where(x => x.OwnerUserId == ownerUserId)
.Include(x => x.Company)
.Include(x => x.Messages)
.FirstOrDefaultAsync(x => x.Id == jobApplicationId, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var importedMessageIds = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && !string.IsNullOrWhiteSpace(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToListAsync(cancellationToken);
var importedThreadIds = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!)
.ToListAsync(cancellationToken);
var importedMessageIdSet = importedMessageIds.ToHashSet(StringComparer.Ordinal);
var importedThreadIdSet = importedThreadIds.ToHashSet(StringComparer.Ordinal);
var queries = BuildJobQueries(job, queryOverride);
var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
var importedMessageIds = job.Messages
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToHashSet(StringComparer.Ordinal);
var importedThreadIds = job.Messages
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!)
.ToHashSet(StringComparer.Ordinal);
var rankedMessages = candidateMessages
.Select(message => ScoreMessage(job, message, importedMessageIds.Contains(message.Message.Id), importedThreadIds.Contains(message.Message.ThreadId)))
.Select(message => _matching.ScoreMessage(job, message, importedMessageIdSet.Contains(message.Message.Id), importedThreadIdSet.Contains(message.Message.ThreadId)))
.Where(result => result.Score > 0 || result.AlreadyImported)
.OrderByDescending(result => result.Score)
.ThenByDescending(result => result.Message.Date ?? DateTimeOffset.MinValue)
@@ -145,6 +181,7 @@ public sealed class GmailController : ControllerBase
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
.Take(8)
.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points))
.ToList();
var matchedQueries = ordered
.SelectMany(item => item.MatchedQueries)
@@ -179,7 +216,7 @@ public sealed class GmailController : ControllerBase
ToConfidence(item.Score),
item.AlreadyImported,
item.MatchedQueries,
item.Reasons)).ToList());
item.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList())).ToList());
})
.OrderByDescending(thread => thread.Score)
.ThenByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
@@ -197,6 +234,517 @@ public sealed class GmailController : ControllerBase
threads));
}
[HttpGet("review-candidates")]
public async Task<ActionResult<GmailReviewQueueResponseDto>> ReviewCandidates(
[FromQuery] string? queryOverride,
[FromQuery] int maxResultsPerQuery = 6,
CancellationToken cancellationToken = default)
{
var ownerUserId = GetRequiredOwnerUserId();
if (await GetOwnerGmailConnectionAsync(ownerUserId, cancellationToken) is null)
{
return GmailNotConnectedResult();
}
var jobs = await _db.JobApplications
.AsNoTracking()
.Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted)
.Include(x => x.Company)
.OrderByDescending(x => x.DateApplied)
.Take(100)
.ToListAsync(cancellationToken);
if (jobs.Count == 0)
{
return Ok(new GmailReviewQueueResponseDto(Array.Empty<string>(), 0, 0, 0, 0, Array.Empty<GmailReviewThreadDto>()));
}
var querySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var job in jobs)
{
foreach (var query in _matching.BuildJobQueries(job, queryOverride))
{
querySet.Add(query);
}
}
var queries = querySet.Take(18).ToList();
var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
var allImportedMessageIds = await _db.Correspondences
.AsNoTracking()
.Where(message => message.JobApplication.OwnerUserId == ownerUserId && !string.IsNullOrWhiteSpace(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToListAsync(cancellationToken);
var allImportedThreadIds = await _db.Correspondences
.AsNoTracking()
.Where(message => message.JobApplication.OwnerUserId == ownerUserId && !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!)
.ToListAsync(cancellationToken);
var allImportedMessageIdSet = allImportedMessageIds.ToHashSet(StringComparer.Ordinal);
var allImportedThreadIdSet = allImportedThreadIds.ToHashSet(StringComparer.Ordinal);
var reviewDecisions = await _db.GmailReviewDecisions
.AsNoTracking()
.Where(decision => decision.OwnerUserId == ownerUserId)
.ToDictionaryAsync(decision => decision.ThreadId, StringComparer.Ordinal, cancellationToken);
var groupedThreads = candidateMessages
.GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal)
.Select(group =>
{
var existingDecision = reviewDecisions.GetValueOrDefault(group.Key);
var orderedMessages = group.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList();
var latestDate = orderedMessages.Max(item => item.Message.Date ?? DateTimeOffset.MinValue);
var subject = orderedMessages.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Message.Subject))?.Message.Subject ?? "(no subject)";
var matchedQueries = orderedMessages.SelectMany(item => item.MatchedQueries).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
var hasImportedMessages = orderedMessages.Any(item => allImportedMessageIdSet.Contains(item.Message.Id) || allImportedThreadIdSet.Contains(item.Message.ThreadId));
var jobCandidates = jobs
.Select(job =>
{
var best = orderedMessages
.Select(item => _matching.ScoreMessage(job, item, allImportedMessageIdSet.Contains(item.Message.Id), allImportedThreadIdSet.Contains(item.Message.ThreadId)))
.OrderByDescending(score => score.Score)
.First();
return new GmailReviewJobCandidateDto(
job.Id,
job.JobTitle,
job.Company?.Name ?? string.Empty,
best.Score,
best.Confidence,
best.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList());
})
.Where(candidate => candidate.Score > 0)
.OrderByDescending(candidate => candidate.Score)
.Take(3)
.ToList();
var topScore = jobCandidates.FirstOrDefault()?.Score ?? 0;
var secondScore = jobCandidates.Skip(1).FirstOrDefault()?.Score ?? 0;
var routing = existingDecision?.Decision switch
{
"linked" => "linked",
"rejected" => "rejected",
"suggested" => "suggested",
_ => topScore >= 30 && topScore - secondScore >= 8
? "auto-link"
: topScore >= 16
? "review"
: "unmatched"
};
var messages = orderedMessages
.Select(item => new GmailJobMatchedMessageDto(
item.Message.Id,
item.Message.ThreadId,
item.Message.Subject,
item.Message.From,
item.Message.To,
item.Message.Date,
item.Message.Snippet,
item.MatchedQueries.Count * 4,
item.MatchedQueries.Count >= 2 ? "medium" : "low",
allImportedMessageIdSet.Contains(item.Message.Id),
item.MatchedQueries,
Array.Empty<GmailJobMatchReasonDto>()))
.ToList();
return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, existingDecision?.Note, matchedQueries, jobCandidates, messages);
})
.OrderByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
.Take(100)
.ToList();
return Ok(new GmailReviewQueueResponseDto(
queries,
groupedThreads.Count,
groupedThreads.Count(thread => thread.Routing == "auto-link"),
groupedThreads.Count(thread => thread.Routing == "review"),
groupedThreads.Count(thread => thread.Routing == "unmatched"),
groupedThreads));
}
[HttpPost("review-decision")]
public async Task<IActionResult> SaveReviewDecision([FromBody] SaveGmailReviewDecisionRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
var decision = (request.Decision ?? string.Empty).Trim().ToLowerInvariant();
if (decision is not ("linked" or "rejected" or "review" or "suggested"))
{
return BadRequest("Decision must be linked, rejected, review, or suggested.");
}
var ownerUserId = GetRequiredOwnerUserId();
JobApplication? job = null;
if (decision == "linked")
{
if (request.JobApplicationId is null or <= 0) return BadRequest("jobApplicationId is required when linking a thread.");
job = await _db.JobApplications
.Where(x => x.OwnerUserId == ownerUserId)
.Include(x => x.Company)
.Include(x => x.Messages)
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId.Value, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var distinctMessageIds = threadMessages
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
.Select(message => message.Id)
.Distinct(StringComparer.Ordinal)
.ToList();
var existingMessageIds = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToListAsync(cancellationToken);
foreach (var messageId in distinctMessageIds)
{
if (existingMessageIds.Contains(messageId, StringComparer.Ordinal)) continue;
await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
}
}
var existing = await _db.GmailReviewDecisions.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == request.ThreadId.Trim(), cancellationToken);
if (existing is null)
{
existing = new GmailReviewDecision
{
OwnerUserId = ownerUserId,
ThreadId = request.ThreadId.Trim(),
};
_db.GmailReviewDecisions.Add(existing);
}
existing.Decision = decision switch
{
"review" => "review",
_ => decision,
};
existing.JobApplicationId = decision == "linked" ? request.JobApplicationId : null;
existing.Note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim();
existing.UpdatedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return Ok(new
{
existing.ThreadId,
existing.Decision,
existing.JobApplicationId,
existing.Note,
existing.UpdatedAt,
});
}
[HttpPost("manual-sync")]
public async Task<ActionResult<GmailManualSyncResultDto>> ManualSync([FromBody] GmailManualSyncRequest? request, CancellationToken cancellationToken)
{
var ownerUserId = GetRequiredOwnerUserId();
if (await GetOwnerGmailConnectionAsync(ownerUserId, cancellationToken) is null)
{
return GmailNotConnectedResult();
}
var jobs = await _db.JobApplications
.AsNoTracking()
.Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted)
.Include(x => x.Company)
.OrderByDescending(x => x.DateApplied)
.Take(100)
.ToListAsync(cancellationToken);
if (jobs.Count == 0)
{
return Ok(new GmailManualSyncResultDto(0, 0, 0, 0, 0, 0, 0, 0, 365, false, DateTimeOffset.UtcNow));
}
var lookbackDays = Math.Clamp(request?.LookbackDays ?? 365, 30, 365);
var maxResultsPerQuery = Math.Clamp(request?.MaxResultsPerQuery ?? 8, 1, 25);
var includeSpamTrash = request?.IncludeSpamTrash ?? false;
var autoImportHighConfidence = request?.AutoImportHighConfidence ?? true;
var querySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var jobItem in jobs)
{
foreach (var query in _matching.BuildJobQueries(jobItem, null))
{
var bounded = ApplySyncBoundary(query, lookbackDays, includeSpamTrash);
querySet.Add(bounded);
}
}
var queries = querySet.Take(24).ToList();
var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
var allImportedMessageIds = await _db.Correspondences
.Where(message => message.JobApplication.OwnerUserId == ownerUserId && !string.IsNullOrWhiteSpace(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToListAsync(cancellationToken);
var allImportedThreadIds = await _db.Correspondences
.Where(message => message.JobApplication.OwnerUserId == ownerUserId && !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!)
.ToListAsync(cancellationToken);
var allImportedMessageIdSet = allImportedMessageIds.ToHashSet(StringComparer.Ordinal);
var allImportedThreadIdSet = allImportedThreadIds.ToHashSet(StringComparer.Ordinal);
var reviewDecisions = await _db.GmailReviewDecisions
.Where(decision => decision.OwnerUserId == ownerUserId)
.ToDictionaryAsync(decision => decision.ThreadId, StringComparer.Ordinal, cancellationToken);
var groupedThreads = candidateMessages
.GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal)
.ToList();
var autoLinked = 0;
var reviewCount = 0;
var unmatchedCount = 0;
var importedMessages = 0;
var importedThreads = 0;
var skippedMessages = 0;
foreach (var threadGroup in groupedThreads)
{
var threadId = threadGroup.Key;
var existingDecision = reviewDecisions.GetValueOrDefault(threadId);
if (string.Equals(existingDecision?.Decision, "rejected", StringComparison.OrdinalIgnoreCase))
{
unmatchedCount++;
continue;
}
var orderedMessages = threadGroup.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList();
var candidates = jobs
.Select(jobItem =>
{
var best = orderedMessages
.Select(item => _matching.ScoreMessage(jobItem, item, allImportedMessageIdSet.Contains(item.Message.Id), allImportedThreadIdSet.Contains(item.Message.ThreadId)))
.OrderByDescending(score => score.Score)
.First();
return new { Job = jobItem, Best = best };
})
.Where(x => x.Best.Score > 0)
.OrderByDescending(x => x.Best.Score)
.Take(3)
.ToList();
var top = candidates.FirstOrDefault();
var secondScore = candidates.Skip(1).FirstOrDefault()?.Best.Score ?? 0;
if (top is not null && autoImportHighConfidence && top.Best.Score >= 30 && top.Best.Score - secondScore >= 8)
{
var distinctMessageIds = orderedMessages.Select(item => item.Message.Id).Distinct(StringComparer.Ordinal).ToList();
var existingIds = await _db.Correspondences
.Where(message => message.JobApplicationId == top.Job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToListAsync(cancellationToken);
foreach (var messageId in distinctMessageIds)
{
if (existingIds.Contains(messageId, StringComparer.Ordinal))
{
skippedMessages++;
continue;
}
await ImportSingleMessageAsync(ownerUserId, top.Job, messageId, cancellationToken);
allImportedMessageIdSet.Add(messageId);
importedMessages++;
}
importedThreads++;
autoLinked++;
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", top.Job.Id, existingDecision?.Note);
continue;
}
if (top is not null && top.Best.Score >= 16)
{
reviewCount++;
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, existingDecision?.Decision == "suggested" ? "suggested" : "review", null, existingDecision?.Note);
continue;
}
unmatchedCount++;
if (LooksLikeJobRelatedThread(orderedMessages))
{
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "suggested", null, existingDecision?.Note);
}
}
await _db.SaveChangesAsync(cancellationToken);
return Ok(new GmailManualSyncResultDto(queries.Count, groupedThreads.Count, autoLinked, reviewCount, unmatchedCount, importedMessages, importedThreads, skippedMessages, lookbackDays, includeSpamTrash, DateTimeOffset.UtcNow));
}
[HttpGet("suggested-jobs")]
public async Task<ActionResult<GmailSuggestedJobsResponseDto>> SuggestedJobs(CancellationToken cancellationToken)
{
var ownerUserId = GetRequiredOwnerUserId();
if (await GetOwnerGmailConnectionAsync(ownerUserId, cancellationToken) is null)
{
return GmailNotConnectedResult();
}
var reviewThreads = await ReviewCandidates(null, 6, cancellationToken);
if (reviewThreads.Result is not OkObjectResult ok || ok.Value is not GmailReviewQueueResponseDto payload)
{
return BadRequest("Unable to compute Gmail suggested jobs.");
}
var items = payload.Threads
.Where(thread => thread.Routing is "unmatched" or "suggested")
.Select(thread => new GmailSuggestedJobCandidateDto(
thread.ThreadId,
thread.Subject,
thread.LatestDate,
ExtractCompanyName(thread.Messages.FirstOrDefault()?.From, thread.Subject),
ExtractRecruiterName(thread.Messages.FirstOrDefault()?.From),
ExtractFirstEmail(thread.Messages.FirstOrDefault()?.From),
ExtractRoleFromSubject(thread.Subject),
thread.Routing,
thread.MatchedQueries,
thread.Messages.FirstOrDefault()?.Snippet ?? string.Empty))
.Where(item => !string.IsNullOrWhiteSpace(item.CompanyName) || !string.IsNullOrWhiteSpace(item.SuggestedJobTitle))
.Take(50)
.ToList();
return Ok(new GmailSuggestedJobsResponseDto(items.Count, items));
}
[HttpPost("create-suggested-job")]
public async Task<ActionResult<CreatedSuggestedGmailJobDto>> CreateSuggestedJob([FromBody] CreateSuggestedGmailJobRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
if (string.IsNullOrWhiteSpace(request.CompanyName)) return BadRequest("CompanyName is required.");
if (string.IsNullOrWhiteSpace(request.JobTitle)) return BadRequest("JobTitle is required.");
var ownerUserId = GetRequiredOwnerUserId();
var companyName = request.CompanyName.Trim();
var jobTitle = request.JobTitle.Trim();
var company = await _db.Companies.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.Name.ToLower() == companyName.ToLower(), cancellationToken);
if (company is null)
{
company = new Company
{
OwnerUserId = ownerUserId,
Name = companyName,
RecruiterName = string.IsNullOrWhiteSpace(request.RecruiterName) ? null : request.RecruiterName.Trim(),
RecruiterEmail = string.IsNullOrWhiteSpace(request.RecruiterEmail) ? null : request.RecruiterEmail.Trim(),
};
_db.Companies.Add(company);
await _db.SaveChangesAsync(cancellationToken);
}
else
{
if (string.IsNullOrWhiteSpace(company.RecruiterName) && !string.IsNullOrWhiteSpace(request.RecruiterName)) company.RecruiterName = request.RecruiterName.Trim();
if (string.IsNullOrWhiteSpace(company.RecruiterEmail) && !string.IsNullOrWhiteSpace(request.RecruiterEmail)) company.RecruiterEmail = request.RecruiterEmail.Trim();
}
var job = new JobApplication
{
OwnerUserId = ownerUserId,
CompanyId = company.Id,
JobTitle = jobTitle,
Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(),
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
DateApplied = DateTime.UtcNow,
};
_db.JobApplications.Add(job);
await _db.SaveChangesAsync(cancellationToken);
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
var imported = 0;
var skipped = 0;
foreach (var messageId in distinctMessageIds)
{
var existing = await _db.Correspondences.AnyAsync(message => message.JobApplicationId == job.Id && message.ExternalMessageId == messageId, cancellationToken);
if (existing)
{
skipped++;
continue;
}
await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
imported++;
}
UpsertReviewDecision(await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken), ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes);
await _db.SaveChangesAsync(cancellationToken);
return Ok(new CreatedSuggestedGmailJobDto(job.Id, company.Id, request.ThreadId.Trim(), imported, skipped));
}
[HttpPost("relink-thread")]
public async Task<ActionResult<GmailRelinkResultDto>> RelinkThread([FromBody] RelinkGmailThreadRequest request, CancellationToken cancellationToken)
{
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
var ownerUserId = GetRequiredOwnerUserId();
var job = await _db.JobApplications
.Where(x => x.OwnerUserId == ownerUserId)
.Include(x => x.Company)
.Include(x => x.Messages)
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var threadId = request.ThreadId.Trim();
var unlinkedMessages = 0;
if (request.RemoveFromOtherJobs)
{
var otherMessages = await _db.Correspondences
.Include(message => message.JobApplication)
.Where(message => message.ExternalThreadId == threadId && message.JobApplicationId != job.Id && message.JobApplication.OwnerUserId == ownerUserId)
.ToListAsync(cancellationToken);
if (otherMessages.Count > 0)
{
_db.Correspondences.RemoveRange(otherMessages);
unlinkedMessages = otherMessages.Count;
}
}
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
var existingMessageIds = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToListAsync(cancellationToken);
var imported = 0;
var skipped = 0;
foreach (var messageId in distinctMessageIds)
{
if (existingMessageIds.Contains(messageId, StringComparer.Ordinal))
{
skipped++;
continue;
}
await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
imported++;
}
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", job.Id, request.Note);
await _db.SaveChangesAsync(cancellationToken);
return Ok(new GmailRelinkResultDto(threadId, job.Id, imported, skipped, unlinkedMessages));
}
[HttpPost("unlink-thread")]
public async Task<ActionResult<GmailUnlinkResultDto>> UnlinkThread([FromBody] UnlinkGmailThreadRequest request, CancellationToken cancellationToken)
{
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
var ownerUserId = GetRequiredOwnerUserId();
var job = await _db.JobApplications
.Where(x => x.OwnerUserId == ownerUserId)
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var threadId = request.ThreadId.Trim();
var messages = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && message.ExternalThreadId == threadId)
.ToListAsync(cancellationToken);
if (messages.Count > 0)
{
_db.Correspondences.RemoveRange(messages);
}
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
var nextDecision = (request.NextDecision ?? "review").Trim().ToLowerInvariant();
if (nextDecision is not ("review" or "suggested" or "rejected")) nextDecision = "review";
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, nextDecision, null, request.Note);
await _db.SaveChangesAsync(cancellationToken);
return Ok(new GmailUnlinkResultDto(threadId, job.Id, messages.Count, nextDecision));
}
[AllowAnonymous]
[HttpGet("oauth/callback")]
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
@@ -398,12 +946,22 @@ public sealed class GmailController : ControllerBase
{
JobApplicationId = job.Id,
From = isMe ? "Me" : "Company",
Direction = isMe ? "outbound" : "inbound",
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
Channel = "Email",
ExternalMessageId = detail.Id,
ExternalThreadId = string.IsNullOrWhiteSpace(detail.ThreadId) ? null : detail.ThreadId.Trim(),
ExternalFrom = string.IsNullOrWhiteSpace(detail.From) ? null : detail.From.Trim(),
ExternalTo = string.IsNullOrWhiteSpace(detail.To) ? null : detail.To.Trim(),
ExternalLabelsJson = detail.Labels.Count == 0 ? null : JsonSerializer.Serialize(detail.Labels),
AttachmentMetadataJson = detail.Attachments.Count == 0 ? null : JsonSerializer.Serialize(detail.Attachments.Select(attachment => new CorrespondenceAttachmentMetadata
{
FileName = attachment.FileName,
MimeType = attachment.MimeType,
SizeBytes = attachment.SizeBytes,
GmailAttachmentId = attachment.GmailAttachmentId,
Inline = attachment.Inline,
})),
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
Date = messageDate,
};
@@ -433,168 +991,82 @@ public sealed class GmailController : ControllerBase
return message;
}
private static IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
private IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
{
var queries = new List<string>();
void Add(string? query)
{
if (!string.IsNullOrWhiteSpace(query))
{
queries.Add(query.Trim());
}
}
Add(queryOverride);
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail))
{
Add($"(from:{job.Company.RecruiterEmail.Trim()} OR to:{job.Company.RecruiterEmail.Trim()}) newer_than:365d");
}
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName))
{
Add($"\"{job.Company.RecruiterName.Trim()}\" newer_than:365d");
}
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && !string.IsNullOrWhiteSpace(job.JobTitle))
{
Add($"\"{job.Company.Name.Trim()}\" \"{job.JobTitle.Trim()}\" newer_than:365d");
}
if (!string.IsNullOrWhiteSpace(job.Company?.Name))
{
Add($"\"{job.Company.Name.Trim()}\" (application OR interview OR recruiter OR role OR position) newer_than:365d");
}
if (!string.IsNullOrWhiteSpace(job.JobTitle))
{
Add($"subject:\"{job.JobTitle.Trim()}\" newer_than:365d");
}
foreach (var subject in job.Messages
.Select(message => message.Subject)
.Where(subject => !string.IsNullOrWhiteSpace(subject))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(2))
{
Add($"subject:\"{subject!.Trim()}\" newer_than:365d");
}
if (queries.Count == 0)
{
Add("newer_than:365d (application OR interview OR recruiter OR role OR position)");
}
return queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
return _matching.BuildJobQueries(job, queryOverride);
}
private static GmailScoredMessage ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported)
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
{
var reasons = new List<GmailJobMatchReasonDto>();
var score = 0;
var message = candidate.Message;
var subject = message.Subject ?? string.Empty;
var from = message.From ?? string.Empty;
var to = message.To ?? string.Empty;
var snippet = message.Snippet ?? string.Empty;
var haystack = $"{subject} {from} {to} {snippet}";
if (candidate.MatchedQueries.Count > 0)
var bounded = (query ?? string.Empty).Trim();
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
{
var queryHitPoints = Math.Min(12, candidate.MatchedQueries.Count * 4);
score += queryHitPoints;
reasons.Add(new GmailJobMatchReasonDto("queryHits", candidate.MatchedQueries.Count.ToString(), queryHitPoints));
bounded = string.IsNullOrWhiteSpace(bounded)
? $"newer_than:{lookbackDays}d"
: $"{bounded} newer_than:{lookbackDays}d";
}
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name))
if (!includeSpamTrash)
{
score += 18;
reasons.Add(new GmailJobMatchReasonDto("company", job.Company.Name.Trim(), 18));
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
}
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail) && (ContainsValue(from, job.Company.RecruiterEmail) || ContainsValue(to, job.Company.RecruiterEmail)))
{
score += 20;
reasons.Add(new GmailJobMatchReasonDto("recruiterEmail", job.Company.RecruiterEmail.Trim(), 20));
}
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName))
{
score += 12;
reasons.Add(new GmailJobMatchReasonDto("recruiter", job.Company.RecruiterName.Trim(), 12));
}
foreach (var token in SplitTerms(job.JobTitle).Take(4))
{
if (!ContainsValue(haystack, token)) continue;
score += 5;
reasons.Add(new GmailJobMatchReasonDto("jobTitle", token, 5));
}
foreach (var subjectLine in job.Messages
.Select(existing => existing.Subject)
.Where(existing => !string.IsNullOrWhiteSpace(existing))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(2))
{
if (!ContainsValue(subject, subjectLine!)) continue;
score += 8;
reasons.Add(new GmailJobMatchReasonDto("existingSubject", subjectLine!.Trim(), 8));
}
if (message.Date is { } messageDate)
{
var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays);
if (ageDays <= 45)
{
score += 4;
reasons.Add(new GmailJobMatchReasonDto("recency", "45d", 4));
}
else if (ageDays <= 180)
{
score += 2;
reasons.Add(new GmailJobMatchReasonDto("recency", "180d", 2));
}
}
if (threadAlreadyImported && !alreadyImported)
{
reasons.Add(new GmailJobMatchReasonDto("status", "thread-already-imported", 0));
}
if (alreadyImported)
{
reasons.Add(new GmailJobMatchReasonDto("status", "already-imported", 0));
}
reasons = reasons
.GroupBy(reason => new { reason.Label, reason.Value, reason.Points })
.Select(group => group.First())
.OrderByDescending(reason => reason.Points)
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
.ToList();
return new GmailScoredMessage(message, alreadyImported, score, candidate.MatchedQueries, reasons);
return bounded.Trim();
}
private static bool ContainsValue(string haystack, string? value)
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
{
return !string.IsNullOrWhiteSpace(value)
&& haystack.Contains(value.Trim(), StringComparison.OrdinalIgnoreCase);
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
if (string.IsNullOrWhiteSpace(sample)) return false;
return sample.Contains("interview", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("application", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("recruit", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("role", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("position", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("offer", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
}
private static IEnumerable<string> SplitTerms(string? value)
private void UpsertReviewDecision(IDictionary<string, GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
{
if (string.IsNullOrWhiteSpace(value)) yield break;
foreach (var token in value
.Split(new[] { ' ', '/', '-', ',', '.', '(', ')', ':' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(token => token.Length >= 3)
.Distinct(StringComparer.OrdinalIgnoreCase))
if (!decisions.TryGetValue(threadId, out var existing))
{
yield return token;
existing = new GmailReviewDecision
{
OwnerUserId = ownerUserId,
ThreadId = threadId,
};
decisions[threadId] = existing;
_db.GmailReviewDecisions.Add(existing);
}
existing.Decision = decision;
existing.JobApplicationId = jobApplicationId;
if (!string.IsNullOrWhiteSpace(note)) existing.Note = note.Trim();
existing.UpdatedAt = DateTimeOffset.UtcNow;
}
private void UpsertReviewDecision(List<GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
{
var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId);
if (existing is null)
{
existing = new GmailReviewDecision
{
OwnerUserId = ownerUserId,
ThreadId = threadId,
};
decisions.Add(existing);
_db.GmailReviewDecisions.Add(existing);
}
existing.Decision = decision;
existing.JobApplicationId = jobApplicationId;
if (!string.IsNullOrWhiteSpace(note)) existing.Note = note.Trim();
existing.UpdatedAt = DateTimeOffset.UtcNow;
}
private static string ToConfidence(int score)
@@ -607,12 +1079,64 @@ public sealed class GmailController : ControllerBase
};
}
private static string? ExtractFirstEmail(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success ? match.Value : null;
}
private static string? ExtractRecruiterName(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Split('<')[0].Trim().Trim('"');
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
}
private static string? ExtractCompanyName(string? from, string? subject)
{
var subjectText = (subject ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(subjectText))
{
var parts = subjectText.Split(new[] { '-', '', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length >= 2) return parts[0];
}
var recruiterName = ExtractRecruiterName(from);
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
}
private static string? ExtractRoleFromSubject(string? subject)
{
if (string.IsNullOrWhiteSpace(subject)) return null;
var trimmed = subject.Trim();
if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase))
{
return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':');
}
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
}
private string GetRequiredOwnerUserId()
{
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
?? throw new InvalidOperationException("Authenticated user id is missing.");
}
private async Task<GmailConnection?> GetOwnerGmailConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{
return await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
}
private ActionResult GmailNotConnectedResult()
{
return Conflict(new
{
code = "gmail_not_connected",
message = "Connect Gmail before using the Gmail review queue.",
});
}
private string GetRedirectUri()
{
var configured = (_cfg["Google:GmailRedirectUri"] ?? _cfg["Google:RedirectUri"] ?? "").Trim();
@@ -652,11 +1176,4 @@ public sealed class GmailController : ControllerBase
</body>
</html>";
}
private sealed record GmailScoredMessage(
GmailMessageSummary Message,
bool AlreadyImported,
int Score,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> Reasons);
}
@@ -1676,6 +1676,9 @@ Canonical profile:
[HttpGet("{id:int}/history")]
public async Task<ActionResult<List<JobEventDto>>> GetHistory([FromRoute] int id, CancellationToken cancellationToken)
{
var exists = await _db.JobApplications.AnyAsync(j => j.Id == id, cancellationToken);
if (!exists) return NotFound();
var items = await _db.JobEvents
.AsNoTracking()
.Where(e => e.JobApplicationId == id)
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
@@ -136,6 +137,7 @@ public sealed class UsersController : ControllerBase
}
[HttpPost("{id}/send-password-reset")]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> SendPasswordReset([FromRoute] string id, CancellationToken cancellationToken)
{
var u = await _users.FindByIdAsync(id);
@@ -173,6 +175,7 @@ public sealed class UsersController : ControllerBase
public sealed record SendTestEmailRequest(string? ToEmail, string? Subject, string? Message);
[HttpPost("send-test-email")]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> SendTestEmail([FromBody] SendTestEmailRequest? request, CancellationToken cancellationToken)
{
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
+2
View File
@@ -3,9 +3,11 @@ FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY JobTrackerApi/JobTrackerApi.csproj JobTrackerApi/
COPY JobTrackerBackend/JobTrackerBackend.csproj JobTrackerBackend/
COPY Data/ Data/
COPY Models/ Models/
COPY JobTrackerApi/ JobTrackerApi/
COPY JobTrackerBackend/ JobTrackerBackend/
RUN dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false
+8 -12
View File
@@ -8,18 +8,14 @@
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Data\**\*.cs" />
<Compile Include="..\Models\**\*.cs" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0" />
<Compile Remove="Controllers\**\*.cs" />
<Compile Remove="Services\**\*.cs" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JobTrackerBackend\JobTrackerBackend.csproj" />
</ItemGroup>
</Project>
+114 -807
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -9,6 +9,7 @@ namespace JobTrackerApi.Services
public string AttachmentsRoot { get; }
public string CvArtifactsRoot { get; }
public string CvExportsRoot { get; }
public string CvBenchmarksRoot { get; }
public AppPaths(IConfiguration cfg, IHostEnvironment env)
{
@@ -39,6 +40,13 @@ namespace JobTrackerApi.Services
Directory.CreateDirectory(cvExportsRoot);
CvExportsRoot = cvExportsRoot;
var cvBenchmarksRoot = (cfg["Data:CvBenchmarksRoot"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(cvBenchmarksRoot)) cvBenchmarksRoot = Path.Combine(DataRoot, "CvBenchmarks");
if (!Path.IsPathRooted(cvBenchmarksRoot)) cvBenchmarksRoot = Path.Combine(env.ContentRootPath, cvBenchmarksRoot);
Directory.CreateDirectory(cvBenchmarksRoot);
CvBenchmarksRoot = cvBenchmarksRoot;
}
public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Http;
namespace JobTrackerApi.Services;
public static class AuthSessionOptions
{
public const string SessionCookieName = "jobtracker_auth";
public const string CsrfCookieName = "XSRF-TOKEN";
public const string CsrfHeaderName = "X-CSRF-TOKEN";
public static CookieOptions BuildSessionCookie(bool persistent, bool secure)
{
var options = new CookieOptions
{
HttpOnly = true,
IsEssential = true,
SameSite = SameSiteMode.Lax,
Secure = secure,
Path = "/",
};
if (persistent)
{
options.Expires = DateTimeOffset.UtcNow.AddDays(30);
options.MaxAge = TimeSpan.FromDays(30);
}
return options;
}
public static CookieOptions BuildCsrfCookie(bool persistent, bool secure)
{
var options = new CookieOptions
{
HttpOnly = false,
IsEssential = true,
SameSite = SameSiteMode.Lax,
Secure = secure,
Path = "/",
};
if (persistent)
{
options.Expires = DateTimeOffset.UtcNow.AddDays(30);
options.MaxAge = TimeSpan.FromDays(30);
}
return options;
}
public static CookieOptions BuildExpiredCookie(bool secure)
{
return new CookieOptions
{
HttpOnly = true,
IsEssential = true,
SameSite = SameSiteMode.Lax,
Secure = secure,
Path = "/",
Expires = DateTimeOffset.UnixEpoch,
MaxAge = TimeSpan.Zero,
};
}
public static CookieOptions BuildExpiredReadableCookie(bool secure)
{
return new CookieOptions
{
HttpOnly = false,
IsEssential = true,
SameSite = SameSiteMode.Lax,
Secure = secure,
Path = "/",
Expires = DateTimeOffset.UnixEpoch,
MaxAge = TimeSpan.Zero,
};
}
}
+1 -10
View File
@@ -16,14 +16,5 @@ public sealed class CurrentUserService : ICurrentUserService
_http = http;
}
public string? UserId
{
get
{
var u = _http.HttpContext?.User;
if (u is null) return null;
if (u.Identity?.IsAuthenticated != true) return null;
return u.FindFirstValue(ClaimTypes.NameIdentifier) ?? u.FindFirstValue("sub");
}
}
public string? UserId => LocalAuthIdentity.GetRequiredUserId(_http.HttpContext?.User);
}
+67
View File
@@ -0,0 +1,67 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
namespace JobTrackerApi.Services;
public sealed record CvBlockClassificationResult(
string? Section,
double? Confidence,
string? Reason,
string? Title,
string? Company,
string? Location,
string? Start,
string? End,
List<string>? Bullets,
List<string>? Summary,
List<string>? Skills);
public interface ICvAiClassifier
{
Task<CvBlockClassificationResult?> ClassifyBlockAsync(string block, CancellationToken cancellationToken = default);
}
public sealed class CvAiClassifier : ICvAiClassifier
{
private readonly IHttpClientFactory _httpClientFactory;
public CvAiClassifier(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<CvBlockClassificationResult?> ClassifyBlockAsync(string block, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(block)) return null;
try
{
var client = _httpClientFactory.CreateClient("ai-service");
var payload = JsonSerializer.Serialize(new { block });
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
using var response = await client.PostAsync("/cv/classify-block", content, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var parsed = await JsonSerializer.DeserializeAsync<CvBlockClassificationResult>(stream, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
}, cancellationToken);
return parsed;
}
catch
{
return null;
}
}
}
public sealed class NoOpCvAiClassifier : ICvAiClassifier
{
public static NoOpCvAiClassifier Instance { get; } = new();
private NoOpCvAiClassifier() { }
public Task<CvBlockClassificationResult?> ClassifyBlockAsync(string block, CancellationToken cancellationToken = default)
=> Task.FromResult<CvBlockClassificationResult?>(null);
}
+58
View File
@@ -0,0 +1,58 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace JobTrackerApi.Services;
public sealed record CvNormalizationResult(
double? Confidence,
string? Reason,
[property: JsonPropertyName("normalized_text")] string? NormalizedText);
public interface ICvAiNormalizer
{
Task<CvNormalizationResult?> NormalizeAsync(string text, CancellationToken cancellationToken = default);
}
public sealed class CvAiNormalizer : ICvAiNormalizer
{
private readonly IHttpClientFactory _httpClientFactory;
public CvAiNormalizer(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<CvNormalizationResult?> NormalizeAsync(string text, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(text)) return null;
try
{
var client = _httpClientFactory.CreateClient("ai-service");
var payload = JsonSerializer.Serialize(new { text });
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
using var response = await client.PostAsync("/cv/normalize", content, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return await JsonSerializer.DeserializeAsync<CvNormalizationResult>(stream, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
}, cancellationToken);
}
catch
{
return null;
}
}
}
public sealed class NoOpCvAiNormalizer : ICvAiNormalizer
{
public static NoOpCvAiNormalizer Instance { get; } = new();
private NoOpCvAiNormalizer() { }
public Task<CvNormalizationResult?> NormalizeAsync(string text, CancellationToken cancellationToken = default)
=> Task.FromResult<CvNormalizationResult?>(null);
}
@@ -0,0 +1,71 @@
using System.Threading.Channels;
using JobTrackerApi.Controllers;
namespace JobTrackerApi.Services;
public interface ICvProcessingQueue
{
ValueTask EnqueueAsync(int runId, CancellationToken cancellationToken = default);
IAsyncEnumerable<int> DequeueAllAsync(CancellationToken cancellationToken);
}
public sealed class CvProcessingQueue : ICvProcessingQueue
{
private readonly Channel<int> _channel = Channel.CreateUnbounded<int>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
});
public ValueTask EnqueueAsync(int runId, CancellationToken cancellationToken = default)
=> _channel.Writer.WriteAsync(runId, cancellationToken);
public IAsyncEnumerable<int> DequeueAllAsync(CancellationToken cancellationToken)
=> _channel.Reader.ReadAllAsync(cancellationToken);
}
public sealed class NoOpCvProcessingQueue : ICvProcessingQueue
{
public static readonly NoOpCvProcessingQueue Instance = new();
public ValueTask EnqueueAsync(int runId, CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
public async IAsyncEnumerable<int> DequeueAllAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
}
public sealed class CvProcessingHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ICvProcessingQueue _queue;
private readonly ILogger<CvProcessingHostedService> _logger;
public CvProcessingHostedService(IServiceScopeFactory scopeFactory, ICvProcessingQueue queue, ILogger<CvProcessingHostedService> logger)
{
_scopeFactory = scopeFactory;
_queue = queue;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var runId in _queue.DequeueAllAsync(stoppingToken))
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var controller = scope.ServiceProvider.GetRequiredService<ProfileCvController>();
await controller.ProcessQueuedRunAsync(runId, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled CV processing worker failure for run {RunId}", runId);
}
}
}
}
+108 -1
View File
@@ -24,6 +24,8 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
"harvard" => RenderHarvard(normalized, candidateName, jobTitle, companyName),
"auckland" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Auckland", roundedPhoto: false, curvedHeader: false),
"edinburgh" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Edinburgh", roundedPhoto: true, curvedHeader: true),
"monarch" => RenderMonarch(normalized, candidateName, jobTitle, companyName, photoDataUrl),
"fjord" => RenderFjord(normalized, candidateName, jobTitle, companyName, photoDataUrl),
_ => RenderAtsMinimal(normalized, candidateName, jobTitle, companyName, photoDataUrl)
};
return new TailoredCvRenderResult(effectiveTemplateId, suggestedFileName, html);
@@ -39,6 +41,8 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
"harvard" => "harvard",
"auckland" => "auckland",
"edinburgh" => "edinburgh",
"monarch" => "monarch",
"fjord" => "fjord",
_ => "ats-minimal"
};
}
@@ -201,6 +205,106 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
</html>";
}
private static string RenderMonarch(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
{
var accent = ResolveAccent(document.RenderOptions.AccentColor);
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
var photoMarkup = showPhoto ? $"<div class=\"monarch-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
var body = RenderMainSections(document, accent, headingStyle: "sidebar");
var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<div class=\"monarch-company\">Tailored toward {Encode(companyName)}</div>";
return $@"<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""utf-8"" />
<title>{Encode(candidateName)} — Monarch</title>
<style>
:root {{ --accent:{accent}; --ink:#1c1917; --muted:#57534e; --paper:#fffdf8; --panel:#f7efe6; --line:#d6c1a8; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#efe7dc; color:var(--ink); font-family:'Times New Roman', Georgia, serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }}
.monarch-shell {{ border:1px solid var(--line); padding:10mm; position:relative; }}
.monarch-shell::before {{ content:''; position:absolute; inset:6mm; border:1px solid color-mix(in srgb, var(--line) 70%, white); pointer-events:none; }}
.monarch-header {{ display:grid; grid-template-columns:1fr auto; gap:6mm; align-items:center; margin-bottom:8mm; }}
.monarch-kicker {{ display:inline-block; text-transform:uppercase; letter-spacing:.3em; font-size:8pt; color:var(--accent); margin-bottom:2mm; }}
.monarch-name {{ margin:0; font-size:28pt; line-height:1.05; }}
.monarch-headline {{ margin-top:2mm; font-size:11pt; color:var(--muted); max-width:130mm; }}
.monarch-company {{ margin-top:2mm; font-size:9pt; color:var(--accent); text-transform:uppercase; letter-spacing:.16em; }}
.monarch-photo {{ width:30mm; height:38mm; border:1px solid var(--line); background:var(--panel); overflow:hidden; }}
.monarch-photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
.monarch-summary {{ margin-bottom:5mm; padding:4mm 5mm; background:var(--panel); border-left:3px solid var(--accent); font-size:10pt; color:var(--muted); }}
{BaseSectionCss(accent, "harvard")}
.section-title {{ text-transform:uppercase; letter-spacing:.12em; font-size:10pt; }}
@page {{ size:A4; margin:0; }}
</style>
</head>
<body>
<main class=""page"">
<section class=""monarch-shell"">
<header class=""monarch-header"">
<div>
<span class=""monarch-kicker"">Executive CV</span>
<h1 class=""monarch-name"">{Encode(candidateName)}</h1>
<div class=""monarch-headline"">{Encode(document.Headline ?? jobTitle)}</div>
{companyMarkup}
</div>
{photoMarkup}
</header>
{(!string.IsNullOrWhiteSpace(jobTitle) ? $"<div class=\"monarch-summary\">Primary role target: {Encode(jobTitle)}</div>" : string.Empty)}
{body}
</section>
</main>
</body>
</html>";
}
private static string RenderFjord(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
{
var accent = ResolveAccent(document.RenderOptions.AccentColor);
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
var body = RenderMainSections(document, accent, headingStyle: "sidebar");
var photoMarkup = showPhoto ? $"<div class=\"fjord-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<span>{Encode(companyName)}</span>";
return $@"<!DOCTYPE html>
<html lang=""en"">
<head>
<meta charset=""utf-8"" />
<title>{Encode(candidateName)} — Fjord</title>
<style>
:root {{ --accent:{accent}; --ink:#102a43; --muted:#486581; --panel:#e6f1f3; --line:#9fb3c8; --paper:#fbfdff; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#d9e8ef; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:0; }}
.fjord-grid {{ display:grid; grid-template-columns:72mm 1fr; min-height:297mm; }}
.fjord-rail {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 15%, white)); color:white; padding:16mm 8mm; }}
.fjord-name {{ margin:0; font-size:21pt; line-height:1.08; }}
.fjord-headline {{ margin-top:2mm; font-size:10pt; opacity:.95; }}
.fjord-meta {{ margin-top:4mm; font-size:8.5pt; display:flex; flex-direction:column; gap:1.2mm; opacity:.9; }}
.fjord-photo {{ width:28mm; height:28mm; border-radius:50%; overflow:hidden; border:2px solid rgba(255,255,255,.65); margin-top:6mm; }}
.fjord-photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
.fjord-main {{ padding:14mm 14mm 14mm 10mm; }}
{BaseSectionCss(accent, "sidebar")}
.section {{ margin-top:5mm; }}
.skills {{ gap:1.5mm; }}
.skill-pill {{ background:var(--panel); border-color:transparent; color:var(--ink); }}
@page {{ size:A4; margin:0; }}
</style>
</head>
<body>
<main class=""page"">
<section class=""fjord-grid"">
<aside class=""fjord-rail"">
<h1 class=""fjord-name"">{Encode(candidateName)}</h1>
<div class=""fjord-headline"">{Encode(document.Headline ?? jobTitle)}</div>
<div class=""fjord-meta""><span>{Encode(jobTitle)}</span>{companyMarkup}<span>Template: Fjord</span></div>
{photoMarkup}
</aside>
<section class=""fjord-main"">{body}</section>
</section>
</main>
</body>
</html>";
}
private static string RenderMainSections(TailoredCvDocument document, string accent, string headingStyle)
{
var sectionOrder = document.RenderOptions.SectionOrder.Count == 0
@@ -291,7 +395,10 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(Encode));
items.Append("<article class=\"entry\">");
items.Append($"<div class=\"entry-title\">{Encode(entry.Qualification)}</div>");
var title = string.IsNullOrWhiteSpace(entry.QualificationLevel)
? entry.Qualification
: $"{entry.Qualification} ({entry.QualificationLevel})";
items.Append($"<div class=\"entry-title\">{Encode(title)}</div>");
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
if (entry.Details.Count > 0) items.Append($"<ul class=\"education-list\">{string.Join(string.Empty, entry.Details.Select(detail => $"<li>{Encode(detail)}</li>"))}</ul>");
items.Append("</article>");
@@ -10,22 +10,26 @@ namespace JobTrackerApi.Services
private readonly ILogger<DailyExportHostedService> _logger;
private readonly IConfiguration _cfg;
private readonly AppPaths _paths;
private readonly IStartupReadiness _startupReadiness;
public DailyExportHostedService(
IServiceProvider sp,
ILogger<DailyExportHostedService> logger,
IConfiguration cfg,
AppPaths paths)
AppPaths paths,
IStartupReadiness startupReadiness)
{
_sp = sp;
_logger = logger;
_cfg = cfg;
_paths = paths;
_startupReadiness = startupReadiness;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var enabled = _cfg.GetValue("Exports:DailyEnabled", true);
await _startupReadiness.WaitUntilReadyAsync(stoppingToken);
if (!enabled)
{
_logger.LogInformation("Daily export disabled (Exports:DailyEnabled=false).");
@@ -71,22 +75,22 @@ namespace JobTrackerApi.Services
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var companies = await db.Companies.AsNoTracking().OrderBy(c => c.Name).ToListAsync(ct);
var jobs = await db.JobApplications.AsNoTracking().OrderByDescending(j => j.DateApplied).ToListAsync(ct);
var correspondence = await db.Correspondences.AsNoTracking().OrderBy(c => c.Date).ToListAsync(ct);
var attachments = await db.Attachments.AsNoTracking().OrderBy(a => a.UploadDate).ToListAsync(ct);
var events = await db.JobEvents.AsNoTracking().OrderBy(e => e.At).ToListAsync(ct);
var rules = await db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(ct);
// If multi-user ownership is present, write one export per owner.
var owners = jobs
.Select(j => j.OwnerUserId)
var owners = await db.JobApplications
.AsNoTracking()
.OrderByDescending(job => job.DateApplied)
.Select(job => job.OwnerUserId)
.Distinct()
.ToList();
.ToListAsync(ct);
if (owners.Count <= 1)
{
var companies = await db.Companies.AsNoTracking().OrderBy(c => c.Name).ToListAsync(ct);
var jobs = await db.JobApplications.AsNoTracking().OrderByDescending(j => j.DateApplied).ToListAsync(ct);
var correspondence = await db.Correspondences.AsNoTracking().OrderBy(c => c.Date).ToListAsync(ct);
var attachments = await db.Attachments.AsNoTracking().OrderBy(a => a.UploadDate).ToListAsync(ct);
var events = await db.JobEvents.AsNoTracking().OrderBy(e => e.At).ToListAsync(ct);
var export = new
{
Version = "dailyexport.v1",
@@ -110,19 +114,23 @@ namespace JobTrackerApi.Services
foreach (var owner in owners)
{
var ownerKey = string.IsNullOrWhiteSpace(owner) ? "_unassigned" : owner;
var ownerJobs = jobs.Where(j => j.OwnerUserId == owner).ToList();
var ownerJobIds = ownerJobs.Select(j => j.Id).ToHashSet();
var ownerJobs = await db.JobApplications
.AsNoTracking()
.Where(job => job.OwnerUserId == owner)
.OrderByDescending(job => job.DateApplied)
.ToListAsync(ct);
var ownerJobIds = ownerJobs.Select(job => job.Id).ToList();
var export = new
{
Version = "dailyexport.v2",
CreatedAt = DateTime.Now,
OwnerUserId = owner,
Companies = companies.Where(c => c.OwnerUserId == owner).ToList(),
Companies = await db.Companies.AsNoTracking().Where(company => company.OwnerUserId == owner).OrderBy(company => company.Name).ToListAsync(ct),
JobApplications = ownerJobs,
Correspondence = correspondence.Where(c => ownerJobIds.Contains(c.JobApplicationId)).ToList(),
Attachments = attachments.Where(a => ownerJobIds.Contains(a.JobApplicationId)).ToList(),
Events = events.Where(e => ownerJobIds.Contains(e.JobApplicationId)).ToList(),
Correspondence = await db.Correspondences.AsNoTracking().Where(message => ownerJobIds.Contains(message.JobApplicationId)).OrderBy(message => message.Date).ToListAsync(ct),
Attachments = await db.Attachments.AsNoTracking().Where(attachment => ownerJobIds.Contains(attachment.JobApplicationId)).OrderBy(attachment => attachment.UploadDate).ToListAsync(ct),
Events = await db.JobEvents.AsNoTracking().Where(jobEvent => ownerJobIds.Contains(jobEvent.JobApplicationId)).OrderBy(jobEvent => jobEvent.At).ToListAsync(ct),
Rules = rules
};
@@ -10,16 +10,19 @@ public sealed class FollowUpReminderHostedService : BackgroundService
private readonly IServiceProvider _services;
private readonly IConfiguration _cfg;
private readonly ILogger<FollowUpReminderHostedService> _logger;
private readonly IStartupReadiness _startupReadiness;
public FollowUpReminderHostedService(IServiceProvider services, IConfiguration cfg, ILogger<FollowUpReminderHostedService> logger)
public FollowUpReminderHostedService(IServiceProvider services, IConfiguration cfg, ILogger<FollowUpReminderHostedService> logger, IStartupReadiness startupReadiness)
{
_services = services;
_cfg = cfg;
_logger = logger;
_startupReadiness = startupReadiness;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _startupReadiness.WaitUntilReadyAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
@@ -0,0 +1,21 @@
namespace JobTrackerApi.Services;
public sealed record GmailSemanticMatchCandidate(
int? JobApplicationId,
string? Confidence,
string? Reason,
IReadOnlyList<string>? ExtractedCompanies,
IReadOnlyList<string>? ExtractedRecruiters,
IReadOnlyList<string>? ExtractedRoles,
IReadOnlyList<string>? ExtractedHints);
public interface IGmailCorrespondenceEnrichmentService
{
Task<GmailSemanticMatchCandidate?> EnrichAsync(string threadSubject, string from, string to, string snippet, string? bodyText, CancellationToken cancellationToken = default);
}
public sealed class NoOpGmailCorrespondenceEnrichmentService : IGmailCorrespondenceEnrichmentService
{
public Task<GmailSemanticMatchCandidate?> EnrichAsync(string threadSubject, string from, string to, string snippet, string? bodyText, CancellationToken cancellationToken = default)
=> Task.FromResult<GmailSemanticMatchCandidate?>(null);
}
@@ -0,0 +1,167 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
public sealed record GmailMatchReason(string Label, string Value, int Points);
public sealed record GmailScoredMessageResult(
GmailMessageSummary Message,
bool AlreadyImported,
int Score,
string Confidence,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailMatchReason> Reasons);
public interface IGmailJobMatchingService
{
IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride);
GmailScoredMessageResult ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported);
}
public sealed class GmailJobMatchingService : IGmailJobMatchingService
{
public IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
{
var queries = new List<string>();
void Add(string? query)
{
if (!string.IsNullOrWhiteSpace(query))
{
queries.Add(query.Trim());
}
}
Add(queryOverride);
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail))
Add($"(from:{job.Company.RecruiterEmail.Trim()} OR to:{job.Company.RecruiterEmail.Trim()}) newer_than:365d");
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName))
Add($"\"{job.Company.RecruiterName.Trim()}\" newer_than:365d");
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && !string.IsNullOrWhiteSpace(job.JobTitle))
Add($"\"{job.Company.Name.Trim()}\" \"{job.JobTitle.Trim()}\" newer_than:365d");
if (!string.IsNullOrWhiteSpace(job.Company?.Name))
Add($"\"{job.Company.Name.Trim()}\" (application OR interview OR recruiter OR role OR position) newer_than:365d");
if (!string.IsNullOrWhiteSpace(job.JobTitle))
Add($"subject:\"{job.JobTitle.Trim()}\" newer_than:365d");
foreach (var subject in job.Messages
.Select(message => message.Subject)
.Where(subject => !string.IsNullOrWhiteSpace(subject))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(2))
{
Add($"subject:\"{subject!.Trim()}\" newer_than:365d");
}
if (queries.Count == 0)
Add("newer_than:365d (application OR interview OR recruiter OR role OR position)");
return queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
public GmailScoredMessageResult ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported)
{
var reasons = new List<GmailMatchReason>();
var score = 0;
var message = candidate.Message;
var subject = message.Subject ?? string.Empty;
var from = message.From ?? string.Empty;
var to = message.To ?? string.Empty;
var snippet = message.Snippet ?? string.Empty;
var haystack = $"{subject} {from} {to} {snippet}";
if (candidate.MatchedQueries.Count > 0)
{
var queryHitPoints = Math.Min(12, candidate.MatchedQueries.Count * 4);
score += queryHitPoints;
reasons.Add(new GmailMatchReason("queryHits", candidate.MatchedQueries.Count.ToString(), queryHitPoints));
}
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name))
{
score += 18;
reasons.Add(new GmailMatchReason("company", job.Company.Name.Trim(), 18));
}
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail) && (ContainsValue(from, job.Company.RecruiterEmail) || ContainsValue(to, job.Company.RecruiterEmail)))
{
score += 20;
reasons.Add(new GmailMatchReason("recruiterEmail", job.Company.RecruiterEmail.Trim(), 20));
}
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName))
{
score += 12;
reasons.Add(new GmailMatchReason("recruiter", job.Company.RecruiterName.Trim(), 12));
}
foreach (var token in SplitTerms(job.JobTitle).Take(4))
{
if (!ContainsValue(haystack, token)) continue;
score += 5;
reasons.Add(new GmailMatchReason("jobTitle", token, 5));
}
foreach (var subjectLine in job.Messages
.Select(existing => existing.Subject)
.Where(existing => !string.IsNullOrWhiteSpace(existing))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(2))
{
if (!ContainsValue(subject, subjectLine!)) continue;
score += 8;
reasons.Add(new GmailMatchReason("existingSubject", subjectLine!.Trim(), 8));
}
if (message.Date is { } messageDate)
{
var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays);
if (ageDays <= 45)
{
score += 4;
reasons.Add(new GmailMatchReason("recency", "45d", 4));
}
else if (ageDays <= 180)
{
score += 2;
reasons.Add(new GmailMatchReason("recency", "180d", 2));
}
}
if (threadAlreadyImported && !alreadyImported)
reasons.Add(new GmailMatchReason("status", "thread-already-imported", 0));
if (alreadyImported)
reasons.Add(new GmailMatchReason("status", "already-imported", 0));
reasons = reasons
.GroupBy(reason => new { reason.Label, reason.Value, reason.Points })
.Select(group => group.First())
.OrderByDescending(reason => reason.Points)
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
.ToList();
return new GmailScoredMessageResult(message, alreadyImported, score, ToConfidence(score), candidate.MatchedQueries, reasons);
}
private static bool ContainsValue(string haystack, string? value)
=> !string.IsNullOrWhiteSpace(value) && haystack.Contains(value.Trim(), StringComparison.OrdinalIgnoreCase);
private static IEnumerable<string> SplitTerms(string? value)
{
if (string.IsNullOrWhiteSpace(value)) yield break;
foreach (var token in value.Split(new[] { ' ', '/', '-', ',', '.', '(', ')', ':' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(token => token.Length >= 3)
.Distinct(StringComparer.OrdinalIgnoreCase))
{
yield return token;
}
}
private static string ToConfidence(int score) => score switch
{
>= 30 => "high",
>= 16 => "medium",
_ => "low"
};
}
+212 -106
View File
@@ -27,7 +27,8 @@ public interface IGmailOAuthService
public sealed record GmailOAuthExchangeResult(string GmailAddress);
public sealed record GmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
public sealed record GmailQueryMatchedMessage(GmailMessageSummary Message, IReadOnlyList<string> MatchedQueries);
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml);
public sealed record GmailMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? GmailAttachmentId, bool Inline);
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList<string> Labels, IReadOnlyList<GmailMessageAttachment> Attachments);
internal sealed class GmailTokenResponse
{
@@ -116,6 +117,12 @@ public sealed class GmailOAuthService : IGmailOAuthService
existing.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(tokens.expires_in - 60, 60));
existing.Scope = tokens.scope?.Trim() ?? Scope;
existing.ConnectedAt = DateTimeOffset.UtcNow;
existing.LastSyncStatus = "connected";
existing.LastSyncSource = "oauth-callback";
existing.LastSyncMode = "connect";
existing.LastSyncError = null;
existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt;
await _db.SaveChangesAsync(cancellationToken);
return new GmailOAuthExchangeResult(existing.GmailAddress);
@@ -148,40 +155,49 @@ public sealed class GmailOAuthService : IGmailOAuthService
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
maxResults = Math.Clamp(maxResults, 1, 25);
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults={maxResults}";
if (!string.IsNullOrWhiteSpace(query))
try
{
url += $"&q={Uri.EscapeDataString(query.Trim())}";
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults={maxResults}";
if (!string.IsNullOrWhiteSpace(query))
{
url += $"&q={Uri.EscapeDataString(query.Trim())}";
}
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
{
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
return Array.Empty<GmailMessageSummary>();
}
var ids = messagesElement.EnumerateArray()
.Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Cast<string>()
.ToList();
var results = new List<GmailMessageSummary>(ids.Count);
foreach (var id in ids)
{
var detail = await GetMessageAsync(ownerUserId, id, cancellationToken);
results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet));
}
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
return results;
}
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
catch (Exception ex)
{
return Array.Empty<GmailMessageSummary>();
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken);
throw;
}
var ids = messagesElement.EnumerateArray()
.Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Cast<string>()
.ToList();
var results = new List<GmailMessageSummary>(ids.Count);
foreach (var id in ids)
{
var detail = await GetMessageAsync(ownerUserId, id, cancellationToken);
results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet));
}
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
return results;
}
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
@@ -233,93 +249,117 @@ public sealed class GmailOAuthService : IGmailOAuthService
return Array.Empty<GmailMessageSummary>();
}
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/threads/{Uri.EscapeDataString(threadId.Trim())}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
try
{
return Array.Empty<GmailMessageSummary>();
}
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var results = new List<GmailMessageSummary>();
foreach (var messageElement in messagesElement.EnumerateArray())
{
var id = messageElement.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) continue;
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/threads/{Uri.EscapeDataString(threadId.Trim())}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var messageThreadId = messageElement.TryGetProperty("threadId", out var messageThreadIdEl)
? messageThreadIdEl.GetString() ?? threadId.Trim()
: threadId.Trim();
var snippet = messageElement.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? string.Empty : string.Empty;
var payload = messageElement.TryGetProperty("payload", out var payloadEl) ? payloadEl : default;
var headers = payload.ValueKind == JsonValueKind.Object ? ReadHeaders(payload) : new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
DateTimeOffset? date = null;
if (headers.TryGetValue("date", out var dateHeader) && DateTimeOffset.TryParse(dateHeader, out var parsedDate))
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
{
date = parsedDate;
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", true, null, cancellationToken);
return Array.Empty<GmailMessageSummary>();
}
results.Add(new GmailMessageSummary(
id.Trim(),
messageThreadId,
headers.TryGetValue("subject", out var subject) ? subject : string.Empty,
headers.TryGetValue("from", out var from) ? from : string.Empty,
headers.TryGetValue("to", out var to) ? to : string.Empty,
date,
snippet));
}
var results = new List<GmailMessageSummary>();
foreach (var messageElement in messagesElement.EnumerateArray())
{
var id = messageElement.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) continue;
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
return results;
var messageThreadId = messageElement.TryGetProperty("threadId", out var messageThreadIdEl)
? messageThreadIdEl.GetString() ?? threadId.Trim()
: threadId.Trim();
var snippet = messageElement.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? string.Empty : string.Empty;
var payload = messageElement.TryGetProperty("payload", out var payloadEl) ? payloadEl : default;
var headers = payload.ValueKind == JsonValueKind.Object ? ReadHeaders(payload) : new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
DateTimeOffset? date = null;
if (headers.TryGetValue("date", out var dateHeader) && DateTimeOffset.TryParse(dateHeader, out var parsedDate))
{
date = parsedDate;
}
results.Add(new GmailMessageSummary(
id.Trim(),
messageThreadId,
headers.TryGetValue("subject", out var subject) ? subject : string.Empty,
headers.TryGetValue("from", out var from) ? from : string.Empty,
headers.TryGetValue("to", out var to) ? to : string.Empty,
date,
snippet));
}
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", true, null, cancellationToken);
return results;
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", false, ex.Message, cancellationToken);
throw;
}
}
public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages/{Uri.EscapeDataString(messageId)}?format=full";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var root = doc.RootElement;
var threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : "";
var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : "";
var payload = root.GetProperty("payload");
var headers = ReadHeaders(payload);
var bodyText = ExtractBody(payload, "text/plain");
var bodyHtml = ExtractBody(payload, "text/html");
if (string.IsNullOrWhiteSpace(bodyText) && !string.IsNullOrWhiteSpace(bodyHtml))
try
{
bodyText = StripHtml(bodyHtml);
}
else if (LooksLikeHtml(bodyText))
{
bodyText = StripHtml(bodyText);
}
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
return new GmailMessageDetail(
messageId,
threadId,
headers.TryGetValue("subject", out var subject) ? subject : "",
headers.TryGetValue("from", out var from) ? from : "",
headers.TryGetValue("to", out var to) ? to : "",
headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null,
snippet,
bodyText.Trim(),
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml
);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages/{Uri.EscapeDataString(messageId)}?format=full";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var root = doc.RootElement;
var threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : "";
var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : "";
var labels = root.TryGetProperty("labelIds", out var labelIdsEl) && labelIdsEl.ValueKind == JsonValueKind.Array
? labelIdsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
: new List<string>();
var payload = root.GetProperty("payload");
var headers = ReadHeaders(payload);
var attachments = ReadAttachments(payload);
var bodyText = ExtractBody(payload, "text/plain");
var bodyHtml = ExtractBody(payload, "text/html");
if (string.IsNullOrWhiteSpace(bodyText) && !string.IsNullOrWhiteSpace(bodyHtml))
{
bodyText = StripHtml(bodyHtml);
}
else if (LooksLikeHtml(bodyText))
{
bodyText = StripHtml(bodyText);
}
await TouchSyncStateAsync(ownerUserId, "message-detail", "gmail-message", true, null, cancellationToken);
return new GmailMessageDetail(
messageId,
threadId,
headers.TryGetValue("subject", out var subject) ? subject : "",
headers.TryGetValue("from", out var from) ? from : "",
headers.TryGetValue("to", out var to) ? to : "",
headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null,
snippet,
bodyText.Trim(),
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml,
labels,
attachments
);
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "message-detail", "gmail-message", false, ex.Message, cancellationToken);
throw;
}
}
private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
@@ -435,13 +475,37 @@ public sealed class GmailOAuthService : IGmailOAuthService
}
private async Task TouchSyncTimeAsync(string ownerUserId, CancellationToken cancellationToken)
{
await TouchSyncStateAsync(ownerUserId, "sync", "gmail", true, null, cancellationToken);
}
private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken)
{
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (connection is null) return;
connection.LastSyncedAt = DateTimeOffset.UtcNow;
var now = DateTimeOffset.UtcNow;
connection.LastSyncAttemptedAt = now;
connection.LastSyncMode = mode;
connection.LastSyncSource = source;
connection.LastSyncStatus = succeeded ? "success" : "error";
connection.LastSyncError = succeeded ? null : TrimError(error);
if (succeeded)
{
connection.LastSyncedAt = now;
connection.LastSyncSucceededAt = now;
}
await _db.SaveChangesAsync(cancellationToken);
}
private static string? TrimError(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Trim();
return trimmed.Length <= 300 ? trimmed : trimmed[..300];
}
private string GetRequiredClientId()
{
return (_cfg["Google:ClientId"] ?? _cfg["Auth:GoogleClientId"] ?? "").Trim() switch
@@ -481,6 +545,48 @@ public sealed class GmailOAuthService : IGmailOAuthService
return result;
}
private static List<GmailMessageAttachment> ReadAttachments(JsonElement payload)
{
var results = new List<GmailMessageAttachment>();
ReadAttachmentsRecursive(payload, results);
return results;
}
private static void ReadAttachmentsRecursive(JsonElement payload, List<GmailMessageAttachment> results)
{
var body = payload.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind == JsonValueKind.Object
? bodyEl
: default;
var gmailAttachmentId = body.ValueKind == JsonValueKind.Object && body.TryGetProperty("attachmentId", out var attachmentIdEl) && attachmentIdEl.ValueKind == JsonValueKind.String
? attachmentIdEl.GetString()
: null;
var filename = payload.TryGetProperty("filename", out var filenameEl) ? filenameEl.GetString() : null;
var mimeType = payload.TryGetProperty("mimeType", out var mimeTypeEl) ? mimeTypeEl.GetString() : null;
var sizeBytes = body.ValueKind == JsonValueKind.Object && body.TryGetProperty("size", out var sizeEl) && sizeEl.ValueKind == JsonValueKind.Number
? sizeEl.GetInt64()
: (long?)null;
var disposition = payload.TryGetProperty("headers", out var headersEl) && headersEl.ValueKind == JsonValueKind.Array
? headersEl.EnumerateArray()
.Where(h => h.TryGetProperty("name", out var n) && string.Equals(n.GetString(), "Content-Disposition", StringComparison.OrdinalIgnoreCase))
.Select(h => h.TryGetProperty("value", out var v) ? v.GetString() : null)
.FirstOrDefault()
: null;
var isInline = !string.IsNullOrWhiteSpace(disposition) && disposition.Contains("inline", StringComparison.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(gmailAttachmentId) || !string.IsNullOrWhiteSpace(filename))
{
results.Add(new GmailMessageAttachment(filename, mimeType, sizeBytes, gmailAttachmentId, isInline));
}
if (payload.TryGetProperty("parts", out var partsEl) && partsEl.ValueKind == JsonValueKind.Array)
{
foreach (var part in partsEl.EnumerateArray())
{
ReadAttachmentsRecursive(part, results);
}
}
}
private static string ExtractBody(JsonElement payload, string mimeType)
{
if (payload.TryGetProperty("mimeType", out var mimeTypeEl) &&
@@ -9,15 +9,18 @@ public sealed class JobEnrichmentHostedService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<JobEnrichmentHostedService> _logger;
private readonly IStartupReadiness _startupReadiness;
public JobEnrichmentHostedService(IServiceProvider services, ILogger<JobEnrichmentHostedService> logger)
public JobEnrichmentHostedService(IServiceProvider services, ILogger<JobEnrichmentHostedService> logger, IStartupReadiness startupReadiness)
{
_services = services;
_logger = logger;
_startupReadiness = startupReadiness;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _startupReadiness.WaitUntilReadyAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
@@ -0,0 +1,14 @@
using System.Net;
namespace JobTrackerApi.Services.JobImport;
public interface IHostAddressResolver
{
Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken);
}
public sealed class DnsHostAddressResolver : IHostAddressResolver
{
public Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken)
=> Dns.GetHostAddressesAsync(host, cancellationToken);
}
@@ -15,32 +15,38 @@ public sealed class JobImportService
private readonly UniversalJobParser _universal;
private readonly IEnumerable<IJobSitePlugin> _plugins;
private readonly ITranslationService _translation;
private readonly IHostAddressResolver _hostAddressResolver;
public JobImportService(
IHttpClientFactory httpClientFactory,
UniversalJobParser universal,
IEnumerable<IJobSitePlugin> plugins,
ITranslationService translation)
ITranslationService translation,
IHostAddressResolver hostAddressResolver)
{
_httpClientFactory = httpClientFactory;
_universal = universal;
_plugins = plugins;
_translation = translation;
_hostAddressResolver = hostAddressResolver;
}
public async Task<JobImportResult> PreviewAsync(string url, CancellationToken cancellationToken)
{
if (!TryValidateUrl(url, out var normalized, out var error))
var validation = await ValidateUrlAsync(url, cancellationToken);
if (!validation.Allowed)
{
return new JobImportResult
{
SourceUrl = url ?? "",
Success = false,
Parser = "none",
Error = error
Error = validation.Error
};
}
var normalized = validation.Normalized;
var html = await FetchHtmlAsync(normalized, cancellationToken);
if (html is null)
{
@@ -124,62 +130,88 @@ public sealed class JobImportService
return System.Text.Encoding.UTF8.GetString(bytes);
}
private static bool TryValidateUrl(string? url, out string normalized, out string error)
private async Task<UrlValidationResult> ValidateUrlAsync(string? url, CancellationToken cancellationToken)
{
normalized = "";
error = "";
if (string.IsNullOrWhiteSpace(url))
{
error = "URL is required.";
return false;
return UrlValidationResult.Reject("URL is required.");
}
if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri))
{
error = "Invalid URL.";
return false;
return UrlValidationResult.Reject("Invalid URL.");
}
if (uri.Scheme is not ("http" or "https"))
{
error = "Only http/https URLs are supported.";
return false;
return UrlValidationResult.Reject("Only http/https URLs are supported.");
}
if (uri.IsLoopback || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase))
{
error = "Local URLs are not allowed.";
return false;
return UrlValidationResult.Reject("Local or private network URLs are not allowed.");
}
// Block literal private IPs.
if (IPAddress.TryParse(uri.Host, out var ip))
{
if (IsPrivateIp(ip))
if (IsBlockedAddress(ip))
{
error = "Private IP URLs are not allowed.";
return false;
return UrlValidationResult.Reject("Local or private network URLs are not allowed.");
}
return UrlValidationResult.Allow(uri.ToString());
}
normalized = uri.ToString();
return true;
IPAddress[] addresses;
try
{
addresses = await _hostAddressResolver.ResolveAsync(uri.Host, cancellationToken);
}
catch
{
return UrlValidationResult.Reject("Host resolution failed.");
}
if (addresses.Length == 0 || addresses.Any(IsBlockedAddress))
{
return UrlValidationResult.Reject("Local or private network URLs are not allowed.");
}
return UrlValidationResult.Allow(uri.ToString());
}
private static bool IsPrivateIp(IPAddress ip)
private static bool IsBlockedAddress(IPAddress ip)
{
if (IPAddress.IsLoopback(ip)) return true;
if (ip.Equals(IPAddress.Any) || ip.Equals(IPAddress.IPv6Any)) return true;
if (ip.Equals(IPAddress.None) || ip.Equals(IPAddress.IPv6None)) return true;
if (ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6Multicast || ip.IsIPv6Teredo) return true;
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
{
var b = ip.GetAddressBytes();
return b[0] == 10 ||
b[0] == 0 ||
b[0] == 127 ||
(b[0] == 100 && b[1] >= 64 && b[1] <= 127) ||
(b[0] == 169 && b[1] == 254) ||
(b[0] == 172 && b[1] >= 16 && b[1] <= 31) ||
(b[0] == 192 && b[1] == 168) ||
(b[0] == 169 && b[1] == 254);
(b[0] == 198 && (b[1] == 18 || b[1] == 19));
}
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
{
return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal;
var bytes = ip.GetAddressBytes();
return (bytes[0] & 0xfe) == 0xfc; // fc00::/7 unique local addresses
}
return false;
}
private sealed record UrlValidationResult(bool Allowed, string Normalized, string Error)
{
public static UrlValidationResult Allow(string normalized) => new(true, normalized, string.Empty);
public static UrlValidationResult Reject(string error) => new(false, string.Empty, error);
}
}
@@ -0,0 +1,17 @@
using System.Security.Claims;
namespace JobTrackerApi.Services;
public static class LocalAuthIdentity
{
public static string? GetRequiredUserId(ClaimsPrincipal? user)
{
if (user?.Identity?.IsAuthenticated != true)
{
return null;
}
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub");
return string.IsNullOrWhiteSpace(userId) ? null : userId;
}
}
+4 -1
View File
@@ -7,14 +7,17 @@ namespace JobTrackerApi.Services
public sealed class RulesHostedService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly IStartupReadiness _startupReadiness;
public RulesHostedService(IServiceProvider services)
public RulesHostedService(IServiceProvider services, IStartupReadiness startupReadiness)
{
_services = services;
_startupReadiness = startupReadiness;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _startupReadiness.WaitUntilReadyAsync(stoppingToken);
// Small initial delay to let app start.
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
namespace JobTrackerApi.Services;
public interface IStartupReadiness
{
Task WaitUntilReadyAsync(CancellationToken cancellationToken);
void MarkReady();
}
public sealed class StartupReadiness : IStartupReadiness
{
private readonly TaskCompletionSource<bool> _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task WaitUntilReadyAsync(CancellationToken cancellationToken)
{
if (_ready.Task.IsCompleted)
{
return Task.CompletedTask;
}
return _ready.Task.WaitAsync(cancellationToken);
}
public void MarkReady()
{
_ready.TrySetResult(true);
}
}
+113 -4
View File
@@ -21,6 +21,14 @@ namespace JobTrackerApi.Services
string? GpuName,
bool? OcrAvailable,
string? OcrLanguages,
bool? OllamaConfigured,
bool? OllamaReachable,
string? OllamaModel,
bool? OllamaModelAvailable,
string? OllamaVersion,
IReadOnlyList<string>? OllamaInstalledModels,
IReadOnlyList<string>? OllamaLoadedModels,
int? OllamaLoadedCount,
double? HealthLatencyMs,
double? ProbeLatencyMs,
DateTimeOffset? LastProbeAt,
@@ -62,10 +70,12 @@ namespace JobTrackerApi.Services
public interface ISummarizerService : IAiService
{
new Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40);
}
public class SummarizerService : ISummarizerService
{
private const int AiSummarizeMaxInputChars = 20000;
private readonly IHttpClientFactory _httpFactory;
private readonly IMemoryCache _cache;
private readonly object _metricsLock = new();
@@ -101,6 +111,35 @@ namespace JobTrackerApi.Services
return $"summ:{hash}";
}
private static async Task<string> ReadErrorBodyAsync(HttpResponseMessage response, CancellationToken cancellationToken = default)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(body))
{
return $"HTTP {(int)response.StatusCode}";
}
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("detail", out var detailEl) && detailEl.ValueKind == JsonValueKind.String)
{
return $"HTTP {(int)response.StatusCode}: {detailEl.GetString()}";
}
if (doc.RootElement.TryGetProperty("message", out var messageEl) && messageEl.ValueKind == JsonValueKind.String)
{
return $"HTTP {(int)response.StatusCode}: {messageEl.GetString()}";
}
}
catch (JsonException)
{
}
body = body.Length <= 400 ? body : body[..400];
return $"HTTP {(int)response.StatusCode}: {body}";
}
public async Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30)
{
if (string.IsNullOrWhiteSpace(text)) return null;
@@ -110,10 +149,27 @@ namespace JobTrackerApi.Services
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
{
if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult<string?>(null);
var composed = $"{instruction.Trim()}\n\n{text.Trim()}";
var composed = ComposeBoundedPrompt(instruction.Trim(), text.Trim());
return SummarizeCoreAsync(composed, maxLength, minLength);
}
private static string ComposeBoundedPrompt(string instruction, string text)
{
var prefix = $"{instruction}\n\n";
if (prefix.Length >= AiSummarizeMaxInputChars)
{
return prefix[..AiSummarizeMaxInputChars];
}
var remaining = AiSummarizeMaxInputChars - prefix.Length;
if (text.Length <= remaining)
{
return prefix + text;
}
return prefix + text[..remaining];
}
private async Task<string?> SummarizeCoreAsync(string text, int maxLength, int minLength)
{
var key = BuildCacheKey(text, maxLength, minLength);
@@ -142,7 +198,17 @@ namespace JobTrackerApi.Services
var res = await client.PostAsync("/summarize", content);
sw.Stop();
Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks);
if (!res.IsSuccessStatusCode) return null;
if (!res.IsSuccessStatusCode)
{
var errorBody = await ReadErrorBodyAsync(res);
Interlocked.Increment(ref _failures);
lock (_metricsLock)
{
_lastFailureAt = DateTimeOffset.UtcNow;
_lastError = $"AI summarize failed: {errorBody}";
}
return null;
}
using var stream = await res.Content.ReadAsStreamAsync();
using var doc = await JsonDocument.ParseAsync(stream);
@@ -198,11 +264,12 @@ namespace JobTrackerApi.Services
Interlocked.Add(ref _totalOcrLatencyTicks, sw.ElapsedTicks);
if (!response.IsSuccessStatusCode)
{
var errorBody = await ReadErrorBodyAsync(response, cancellationToken);
Interlocked.Increment(ref _ocrFailures);
lock (_metricsLock)
{
_lastOcrFailureAt = DateTimeOffset.UtcNow;
_lastError = $"AI extraction returned {(int)response.StatusCode}.";
_lastError = $"AI extraction failed: {errorBody}";
}
return null;
}
@@ -259,11 +326,12 @@ namespace JobTrackerApi.Services
if (!res.IsSuccessStatusCode)
{
var errorBody = await ReadErrorBodyAsync(res, cancellationToken);
Interlocked.Increment(ref _probeFailures);
lock (_metricsLock)
{
_lastProbeFailureAt = DateTimeOffset.UtcNow;
_lastError = $"Probe returned {(int)res.StatusCode}.";
_lastError = $"AI probe failed: {errorBody}";
}
return;
}
@@ -310,9 +378,19 @@ namespace JobTrackerApi.Services
string? gpuName = null;
bool? ocrAvailable = null;
string? ocrLanguages = null;
bool? ollamaConfigured = null;
bool? ollamaReachable = null;
string? ollamaModel = null;
bool? ollamaModelAvailable = null;
string? ollamaVersion = null;
List<string>? ollamaInstalledModels = null;
List<string>? ollamaLoadedModels = null;
int? ollamaLoadedCount = null;
double? healthLatencyMs = null;
var healthy = false;
string? healthError = null;
bool? summarizeAvailable = null;
string? modelLoadError = null;
try
{
@@ -332,6 +410,29 @@ namespace JobTrackerApi.Services
if (doc.RootElement.TryGetProperty("gpu_name", out var gpuNameEl)) gpuName = gpuNameEl.GetString();
if (doc.RootElement.TryGetProperty("ocr_available", out var ocrAvailableEl) && ocrAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ocrAvailable = ocrAvailableEl.GetBoolean();
if (doc.RootElement.TryGetProperty("ocr_languages", out var ocrLanguagesEl)) ocrLanguages = ocrLanguagesEl.GetString();
if (doc.RootElement.TryGetProperty("summarize_available", out var summarizeAvailableEl) && summarizeAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) summarizeAvailable = summarizeAvailableEl.GetBoolean();
if (doc.RootElement.TryGetProperty("model_load_error", out var modelLoadErrorEl) && modelLoadErrorEl.ValueKind == JsonValueKind.String) modelLoadError = modelLoadErrorEl.GetString();
if (doc.RootElement.TryGetProperty("ollama_configured", out var ollamaConfiguredEl) && ollamaConfiguredEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaConfigured = ollamaConfiguredEl.GetBoolean();
if (doc.RootElement.TryGetProperty("ollama_reachable", out var ollamaReachableEl) && ollamaReachableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaReachable = ollamaReachableEl.GetBoolean();
if (doc.RootElement.TryGetProperty("ollama_model", out var ollamaModelEl)) ollamaModel = ollamaModelEl.GetString();
if (doc.RootElement.TryGetProperty("ollama_model_available", out var ollamaModelAvailableEl) && ollamaModelAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaModelAvailable = ollamaModelAvailableEl.GetBoolean();
if (doc.RootElement.TryGetProperty("ollama_version", out var ollamaVersionEl)) ollamaVersion = ollamaVersionEl.GetString();
if (doc.RootElement.TryGetProperty("ollama_installed_models", out var ollamaInstalledModelsEl) && ollamaInstalledModelsEl.ValueKind == JsonValueKind.Array)
{
ollamaInstalledModels = ollamaInstalledModelsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList();
}
if (doc.RootElement.TryGetProperty("ollama_loaded_models", out var ollamaLoadedModelsEl) && ollamaLoadedModelsEl.ValueKind == JsonValueKind.Array)
{
ollamaLoadedModels = ollamaLoadedModelsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList();
}
if (doc.RootElement.TryGetProperty("ollama_loaded_count", out var ollamaLoadedCountEl) && ollamaLoadedCountEl.ValueKind == JsonValueKind.Number) ollamaLoadedCount = ollamaLoadedCountEl.GetInt32();
if (summarizeAvailable == false)
{
healthy = false;
healthError = string.IsNullOrWhiteSpace(modelLoadError)
? "AI summarize capability is unavailable."
: modelLoadError;
}
}
else
{
@@ -390,6 +491,14 @@ namespace JobTrackerApi.Services
GpuName: gpuName,
OcrAvailable: ocrAvailable,
OcrLanguages: ocrLanguages,
OllamaConfigured: ollamaConfigured,
OllamaReachable: ollamaReachable,
OllamaModel: ollamaModel,
OllamaModelAvailable: ollamaModelAvailable,
OllamaVersion: ollamaVersion,
OllamaInstalledModels: ollamaInstalledModels,
OllamaLoadedModels: ollamaLoadedModels,
OllamaLoadedCount: ollamaLoadedCount,
HealthLatencyMs: healthLatencyMs,
ProbeLatencyMs: probeLatencyMs,
LastProbeAt: lastProbeAt,
@@ -8,6 +8,7 @@
"Cors": {
"Origins": [
"http://localhost:3000",
"http://localhost:3001",
"https://jobs.cesnimda.uk"
]
},
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!--
Transitional shared-backend project.
The API host and test project both reference this library so controller/service code can
be exercised without dragging the web-entry project into every test build.
Files still live in their original folders for now; a later refactor can move them physically.
-->
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RestoreIgnoreFailedSources>true</RestoreIgnoreFailedSources>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Data\**\*.cs" />
<Compile Include="..\Models\**\*.cs" />
<Compile Include="..\JobTrackerApi\Controllers\**\*.cs" />
<Compile Include="..\JobTrackerApi\Services\**\*.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.14">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
</ItemGroup>
</Project>
+23
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace JobTrackerApi.Models
@@ -11,13 +12,35 @@ namespace JobTrackerApi.Models
[JsonIgnore]
public JobApplication JobApplication { get; set; } = null!;
public string From { get; set; } = ""; // "Me" or "Company"
public string? Direction { get; set; } // inbound, outbound, internal, unknown
public string? Subject { get; set; }
public string? Channel { get; set; } // e.g. Email, Call, Note
public string? ExternalMessageId { get; set; }
public string? ExternalThreadId { get; set; }
public string? ExternalFrom { get; set; }
public string? ExternalTo { get; set; }
public string? ExternalLabelsJson { get; set; }
public string? AttachmentMetadataJson { get; set; }
public string Content { get; set; } = "";
public DateTime Date { get; set; } = DateTime.Now;
[JsonIgnore]
public IReadOnlyList<string> ExternalLabels => string.IsNullOrWhiteSpace(ExternalLabelsJson)
? Array.Empty<string>()
: (System.Text.Json.JsonSerializer.Deserialize<List<string>>(ExternalLabelsJson) ?? new List<string>());
[JsonIgnore]
public IReadOnlyList<CorrespondenceAttachmentMetadata> AttachmentMetadata => string.IsNullOrWhiteSpace(AttachmentMetadataJson)
? Array.Empty<CorrespondenceAttachmentMetadata>()
: (System.Text.Json.JsonSerializer.Deserialize<List<CorrespondenceAttachmentMetadata>>(AttachmentMetadataJson) ?? new List<CorrespondenceAttachmentMetadata>());
}
public sealed class CorrespondenceAttachmentMetadata
{
public string? FileName { get; set; }
public string? MimeType { get; set; }
public long? SizeBytes { get; set; }
public string? GmailAttachmentId { get; set; }
public bool Inline { get; set; }
}
}
+6
View File
@@ -11,4 +11,10 @@ public sealed class GmailConnection
public string Scope { get; set; } = "";
public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? LastSyncedAt { get; set; }
public DateTimeOffset? LastSyncAttemptedAt { get; set; }
public DateTimeOffset? LastSyncSucceededAt { get; set; }
public string? LastSyncMode { get; set; }
public string? LastSyncSource { get; set; }
public string? LastSyncStatus { get; set; }
public string? LastSyncError { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace JobTrackerApi.Models;
public sealed class GmailReviewDecision
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = "";
public string ThreadId { get; set; } = "";
public int? JobApplicationId { get; set; }
public string Decision { get; set; } = "review"; // review, linked, rejected, suggested
public string? Note { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+162
View File
@@ -0,0 +1,162 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace JobTrackerApi.Models;
public static class HumanLanguageCatalog
{
private static readonly Dictionary<string, string> LanguageLookup = BuildLanguageLookup();
private static readonly Regex WordRegex = new(@"\p{L}+", RegexOptions.Compiled);
private static readonly Regex LevelRegex = new(
@"\b(native(?:\s+speaker)?|fluent|advanced|intermediate|beginner|basic|conversational|elementary|professional\s+working\s+proficiency|working\s+proficiency|limited\s+working\s+proficiency|full\s+professional\s+proficiency|a1|a2|b1|b2|c1|c2|a1\s*/\s*a2|a2\s*/\s*b1|b1\s*/\s*b2|b2\s*/\s*c1|c1\s*/\s*c2)\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static string? NormalizeLanguageName(string? raw)
{
var matches = ExtractLanguageNames(raw);
return matches.Count == 1 ? matches[0] : null;
}
public static IReadOnlyList<string> ExtractLanguageNames(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<string>();
var words = WordRegex.Matches(raw)
.Select(match => match.Value)
.Where(value => !string.IsNullOrWhiteSpace(value))
.ToList();
if (words.Count == 0) return Array.Empty<string>();
var matches = new List<(int Start, int Size, string Canonical)>();
for (var size = Math.Min(4, words.Count); size >= 1; size--)
{
for (var start = 0; start <= words.Count - size; start++)
{
var phrase = string.Join(" ", words.Skip(start).Take(size));
if (!LanguageLookup.TryGetValue(NormalizeKey(phrase), out var canonical)) continue;
if (matches.Any(existing => RangesOverlap(existing.Start, existing.Size, start, size))) continue;
matches.Add((start, size, canonical));
}
}
return matches
.OrderBy(match => match.Start)
.Select(match => match.Canonical)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
public static bool HasRecognizedLevel(string? raw)
{
return ExtractLevel(raw) is not null;
}
public static string? ExtractLevel(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var match = LevelRegex.Match(raw);
if (!match.Success) return null;
var value = match.Groups[1].Value.Trim();
var compact = Regex.Replace(value, @"\s+", " ");
return compact.ToLowerInvariant() switch
{
"native speaker" => "Native",
"native" => "Native",
"fluent" => "Fluent",
"advanced" => "Advanced",
"intermediate" => "Intermediate",
"beginner" => "Beginner",
"basic" => "Basic",
"conversational" => "Conversational",
"elementary" => "Elementary",
"professional working proficiency" => "Professional working proficiency",
"working proficiency" => "Working proficiency",
"limited working proficiency" => "Limited working proficiency",
"full professional proficiency" => "Full professional proficiency",
_ when Regex.IsMatch(compact, @"^[ABC][12](?:\s*/\s*[ABC][12])?$", RegexOptions.IgnoreCase) => compact.ToUpperInvariant().Replace(" ", string.Empty),
_ => compact,
};
}
private static bool RangesOverlap(int startA, int sizeA, int startB, int sizeB)
{
var endA = startA + sizeA;
var endB = startB + sizeB;
return startA < endB && startB < endA;
}
private static Dictionary<string, string> BuildLanguageLookup()
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
void Add(string? alias, string? canonical)
{
var normalizedAlias = NormalizeKey(alias);
var normalizedCanonical = NormalizeDisplayName(canonical);
if (string.IsNullOrWhiteSpace(normalizedAlias) || string.IsNullOrWhiteSpace(normalizedCanonical)) return;
map.TryAdd(normalizedAlias, normalizedCanonical);
}
foreach (var culture in CultureInfo.GetCultures(CultureTypes.NeutralCultures | CultureTypes.SpecificCultures))
{
var english = CleanCultureLanguageName(culture.EnglishName);
var native = CleanCultureLanguageName(culture.NativeName);
Add(english, english);
Add(native, english);
}
Add("norsk", "Norwegian");
Add("bokmal", "Norwegian");
Add("bokmål", "Norwegian");
Add("nynorsk", "Norwegian");
Add("mandarin", "Chinese");
Add("cantonese", "Chinese");
Add("farsi", "Persian");
Add("persian", "Persian");
return map;
}
private static string? CleanCultureLanguageName(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var cleaned = value.Trim();
var parenIndex = cleaned.IndexOf('(');
if (parenIndex > 0) cleaned = cleaned[..parenIndex].Trim();
var commaIndex = cleaned.IndexOf(',');
if (commaIndex > 0) cleaned = cleaned[..commaIndex].Trim();
return NormalizeDisplayName(cleaned);
}
private static string? NormalizeDisplayName(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var cleaned = Regex.Replace(value.Trim(), @"\s+", " ");
return string.Join(" ", cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(word => word.Length <= 3 && word.All(char.IsUpper)
? word
: char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant()));
}
private static string NormalizeKey(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var decomposed = value.Trim().Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(decomposed.Length);
foreach (var ch in decomposed)
{
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.NonSpacingMark) continue;
builder.Append(char.ToLowerInvariant(ch));
}
return Regex.Replace(builder.ToString().Normalize(NormalizationForm.FormC), @"[^\p{L}]+", " ").Trim();
}
}
+23
View File
@@ -8,6 +8,8 @@ public sealed class StructuredCvProfile
public List<string> Summary { get; set; } = new();
public List<StructuredCvJob> Jobs { get; set; } = new();
public List<StructuredCvEducation> Education { get; set; } = new();
public List<StructuredCvCertification> Certifications { get; set; } = new();
public List<StructuredCvProject> Projects { get; set; } = new();
public List<string> Skills { get; set; } = new();
public List<StructuredCvLanguage> Languages { get; set; } = new();
public List<string> Interests { get; set; } = new();
@@ -60,6 +62,7 @@ public sealed class StructuredCvJob
public sealed class StructuredCvEducation
{
public string? Qualification { get; set; }
public string? QualificationLevel { get; set; }
public string? Institution { get; set; }
public string? Location { get; set; }
public string? Start { get; set; }
@@ -67,6 +70,26 @@ public sealed class StructuredCvEducation
public List<string> Details { get; set; } = new();
}
public sealed class StructuredCvCertification
{
public string? Name { get; set; }
public string? Issuer { get; set; }
public string? Location { get; set; }
public string? Date { get; set; }
public List<string> Details { get; set; } = new();
}
public sealed class StructuredCvProject
{
public string? Name { get; set; }
public string? Role { get; set; }
public string? Location { get; set; }
public string? Start { get; set; }
public string? End { get; set; }
public List<string> Bullets { get; set; } = new();
public List<string> Skills { get; set; } = new();
}
public sealed class StructuredCvLanguage
{
public string? Name { get; set; }
+554 -29
View File
@@ -12,6 +12,13 @@ public static class StructuredCvProfileJson
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private static readonly HashSet<string> NonLocationTokens = new(StringComparer.OrdinalIgnoreCase)
{
"python", "ruby", "sql", "mysql", "postgresql", "postgres", "sqlite", "javascript", "typescript",
"react", "node", "node.js", "c#", ".net", "asp.net", "java", "azure", "aws", "gcp", "docker",
"kubernetes", "terraform", "git", "github", "gitlab", "ci/cd", "rest", "graphql", "php", "golang", "go"
};
public static StructuredCvProfile Empty() => Normalize(new StructuredCvProfile());
public static StructuredCvProfile Deserialize(string? json)
@@ -60,6 +67,8 @@ public static class StructuredCvProfileJson
: primary.Summary.Concat(secondary.Summary).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (primary.Jobs.Count == 0) primary.Jobs = secondary.Jobs;
if (primary.Education.Count == 0) primary.Education = secondary.Education;
if (primary.Certifications.Count == 0) primary.Certifications = secondary.Certifications;
if (primary.Projects.Count == 0) primary.Projects = secondary.Projects;
primary.Skills = primary.Skills.Count == 0
? secondary.Skills
: primary.Skills.Concat(secondary.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
@@ -125,6 +134,14 @@ public static class StructuredCvProfileJson
case "education":
profile.Education = ParseEducation(section.Content);
break;
case "certifications":
case "certificates":
profile.Certifications = ParseCertifications(section.Content);
break;
case "projects":
case "selected projects":
profile.Projects = ParseProjects(section.Content);
break;
default:
profile.OtherSections.Add(new StructuredCvOtherSection
{
@@ -144,7 +161,7 @@ public static class StructuredCvProfileJson
profile.Version = string.IsNullOrWhiteSpace(profile.Version) ? "1" : profile.Version.Trim();
profile.Metadata ??= new StructuredCvMetadata();
profile.Metadata.Fields ??= new Dictionary<string, StructuredCvFieldMetadata>();
profile.Contact ??= new StructuredCvContact();
profile.Contact = NormalizeContact(profile.Contact);
profile.Summary = CleanList(profile.Summary);
profile.Jobs = (profile.Jobs ?? new List<StructuredCvJob>())
.Select(NormalizeJob)
@@ -158,6 +175,18 @@ public static class StructuredCvProfileJson
|| !string.IsNullOrWhiteSpace(education.Institution)
|| education.Details.Count > 0)
.ToList();
profile.Certifications = (profile.Certifications ?? new List<StructuredCvCertification>())
.Select(NormalizeCertification)
.Where(certification => !string.IsNullOrWhiteSpace(certification.Name)
|| !string.IsNullOrWhiteSpace(certification.Issuer)
|| certification.Details.Count > 0)
.ToList();
profile.Projects = (profile.Projects ?? new List<StructuredCvProject>())
.Select(NormalizeProject)
.Where(project => !string.IsNullOrWhiteSpace(project.Name)
|| !string.IsNullOrWhiteSpace(project.Role)
|| project.Bullets.Count > 0)
.ToList();
profile.Skills = CleanList(profile.Skills);
profile.Languages = (profile.Languages ?? new List<StructuredCvLanguage>())
.Select(NormalizeLanguage)
@@ -178,37 +207,320 @@ public static class StructuredCvProfileJson
return profile;
}
private static StructuredCvContact NormalizeContact(StructuredCvContact? contact)
{
contact ??= new StructuredCvContact();
contact.FullName = TrimOrNull(contact.FullName);
contact.Headline = TrimOrNull(contact.Headline);
contact.Email = TrimOrNull(contact.Email);
contact.Phone = TrimOrNull(contact.Phone);
contact.Location = NormalizeLocationValue(contact.Location);
contact.Website = NormalizeWebsite(contact.Website);
contact.LinkedIn = NormalizeLinkedIn(contact.LinkedIn);
return contact;
}
private static StructuredCvJob NormalizeJob(StructuredCvJob? job)
{
job ??= new StructuredCvJob();
job.Title = TrimOrNull(job.Title);
job.Company = TrimOrNull(job.Company);
job.Location = TrimOrNull(job.Location);
job.Start = TrimOrNull(job.Start);
job.End = TrimOrNull(job.End);
job.Bullets = CleanList(job.Bullets);
var title = NormalizeJobTitle(job.Title);
var company = NormalizeCompanyName(job.Company);
var location = NormalizeLocationValue(job.Location);
if (!string.IsNullOrWhiteSpace(title) && company is null)
{
var atSplit = Regex.Match(title, @"^(?<title>.+?)\s+at\s+(?<company>.+)$", RegexOptions.IgnoreCase);
if (atSplit.Success)
{
title = NormalizeJobTitle(atSplit.Groups["title"].Value);
company = NormalizeCompanyName(atSplit.Groups["company"].Value);
}
}
if (!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(company))
{
var titleLooksLikeCompany = LooksLikeCompanyName(title) && !LooksLikeJobTitle(title);
var companyLooksLikeTitle = LooksLikeJobTitle(company) && !LooksLikeCompanyName(company);
if (titleLooksLikeCompany && companyLooksLikeTitle)
{
(title, company) = (company, title);
}
}
if (!string.IsNullOrWhiteSpace(title) && !LooksLikeJobTitle(title) && LooksLikeCompanyName(title))
{
if (company is null) company = title;
title = null;
}
if (!string.IsNullOrWhiteSpace(company) && !LooksLikeCompanyName(company) && LooksLikeJobTitle(company) && title is null)
{
title = company;
company = null;
}
job.Title = title;
job.Company = company;
job.Location = location;
job.Start = NormalizeDateValue(job.Start);
job.End = NormalizeDateValue(job.End);
job.Bullets = CleanList(job.Bullets)
.Select(NormalizeBullet)
.Where(bullet => bullet is not null)
.Select(bullet => bullet!)
.Where(bullet => IsUsefulJobBullet(bullet, job.Title, job.Company))
.ToList();
job.Skills = CleanList(job.Skills);
job.IsCurrent = job.IsCurrent || string.Equals(job.End, "present", StringComparison.OrdinalIgnoreCase) || string.Equals(job.End, "current", StringComparison.OrdinalIgnoreCase);
return job;
}
private static string? NormalizeBullet(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
return value.Trim().TrimStart('-', '•', '*', ' ');
}
private static bool IsUsefulJobBullet(string? value, string? title, string? company)
{
var trimmed = TrimOrNull(value);
if (trimmed is null) return false;
if (LooksLikeDateRange(trimmed) || LooksLikeSectionHeading(trimmed) || trimmed.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase)) return false;
if (title is not null && trimmed.Equals(title, StringComparison.OrdinalIgnoreCase)) return false;
if (company is not null && trimmed.Equals(company, StringComparison.OrdinalIgnoreCase)) return false;
if (trimmed.Length < 12 && !trimmed.Contains(' ')) return false;
return true;
}
private static string? NormalizeJobTitle(string? value)
{
var trimmed = TrimOrNull(value);
if (trimmed is null) return null;
if (LooksLikeDateRange(trimmed) || LooksLikeSectionHeading(trimmed) || LooksLikeUrlOrEmail(trimmed)) return null;
trimmed = Regex.Replace(trimmed, @"\s+", " ").Trim(' ', '|', ',', '-', ':');
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
}
private static string? NormalizeCompanyName(string? value)
{
var trimmed = TrimOrNull(value);
if (trimmed is null) return null;
if (LooksLikeDateRange(trimmed) || LooksLikeSectionHeading(trimmed) || LooksLikeUrlOrEmail(trimmed)) return null;
if (trimmed.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase)) return null;
if (trimmed.Contains('.') && trimmed.Contains(' ')) return null;
trimmed = Regex.Replace(trimmed, @"\s+", " ").Trim(' ', '|', ',', '-', ':');
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
}
private static string? NormalizeLocationValue(string? value)
{
var trimmed = TrimOrNull(value);
if (trimmed is null) return null;
if (LooksLikeDateRange(trimmed) || LooksLikeSectionHeading(trimmed) || LooksLikeUrlOrEmail(trimmed)) return null;
if (trimmed.Any(char.IsDigit) || trimmed.Length > 80) return null;
var normalized = Regex.Replace(trimmed, @"\s+[A-Z](?:\s+[A-Z]){2,}(?:\b.*)?$", string.Empty).Trim();
normalized = Regex.Replace(normalized, @"\b(?:remote|hybrid)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim();
normalized = Regex.Replace(normalized, @"\b(?:sales representative|developer|engineer|manager|consultant|analyst|designer|specialist|technician)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim();
normalized = Regex.Replace(normalized, @"\s+", " ").Trim(' ', '|', ';', ':');
var parts = normalized.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0 || parts.Length > 4) return null;
if (parts.Any(part => !Regex.IsMatch(part, @"^[\p{L}][\p{L}'\-. ]+$"))) return null;
if (parts.Any(LooksLikeSkillToken)) return null;
return string.Join(", ", parts);
}
private static string? NormalizeWebsite(string? value)
{
var trimmed = TrimOrNull(value);
if (trimmed is null) return null;
if (trimmed.Contains("linkedin.com", StringComparison.OrdinalIgnoreCase)) return null;
var candidate = trimmed;
if (!candidate.Contains("://", StringComparison.Ordinal)) candidate = $"https://{candidate}";
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) return null;
var host = uri.Host.Trim().Trim('.').ToLowerInvariant();
if (string.IsNullOrWhiteSpace(host) || !Regex.IsMatch(host, @"^(?:[a-z0-9-]+\.)+[a-z]{2,}$", RegexOptions.IgnoreCase)) return null;
return host;
}
private static string? NormalizeLinkedIn(string? value)
{
var trimmed = TrimOrNull(value);
if (trimmed is null) return null;
var candidate = trimmed;
if (!candidate.Contains("://", StringComparison.Ordinal)) candidate = $"https://{candidate}";
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) return null;
if (!uri.Host.Contains("linkedin.com", StringComparison.OrdinalIgnoreCase)) return null;
var path = uri.AbsolutePath.TrimEnd('/');
if (!Regex.IsMatch(path, @"^/(in|pub)/[^/]+(?:/[^/]+){0,2}$", RegexOptions.IgnoreCase)) return null;
return $"https://www.linkedin.com{path}";
}
private static string? NormalizeDateValue(string? value)
{
var trimmed = TrimOrNull(value);
return trimmed is not null && LooksLikeDateRange(trimmed) ? trimmed : null;
}
private static bool LooksLikeDateRange(string value)
{
return Regex.IsMatch(value, @"^(?:\d{1,2}/\d{1,2}/\d{4}|(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+\d{4}|\d{4}|Present|Current)(?:\s*[-]\s*(?:\d{1,2}/\d{1,2}/\d{4}|(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+\d{4}|\d{4}|Present|Current))?$", RegexOptions.IgnoreCase);
}
private static bool LooksLikeUrlOrEmail(string value)
{
return value.Contains('@')
|| value.Contains("www.", StringComparison.OrdinalIgnoreCase)
|| value.Contains("http://", StringComparison.OrdinalIgnoreCase)
|| value.Contains("https://", StringComparison.OrdinalIgnoreCase);
}
private static bool LooksLikeSectionHeading(string value)
{
return value.Equals("Work Experience", StringComparison.OrdinalIgnoreCase)
|| value.Equals("Experience", StringComparison.OrdinalIgnoreCase)
|| value.Equals("Employment History", StringComparison.OrdinalIgnoreCase)
|| value.Equals("Education", StringComparison.OrdinalIgnoreCase)
|| value.Equals("Skills", StringComparison.OrdinalIgnoreCase)
|| value.Equals("Languages", StringComparison.OrdinalIgnoreCase)
|| value.Equals("Interests", StringComparison.OrdinalIgnoreCase)
|| value.Equals("Contact", StringComparison.OrdinalIgnoreCase)
|| value.Equals("Professional Summary", StringComparison.OrdinalIgnoreCase)
|| value.Equals("Summary", StringComparison.OrdinalIgnoreCase);
}
private static bool LooksLikeJobTitle(string value)
{
if (string.IsNullOrWhiteSpace(value) || LooksLikeDateRange(value) || LooksLikeUrlOrEmail(value)) return false;
return Regex.IsMatch(value, @"\b(developer|engineer|manager|lead|architect|consultant|specialist|analyst|administrator|coordinator|director|designer|intern|officer|owner|founder|teacher|researcher|writer|editor|producer|assistant|technician|supervisor|head)\b", RegexOptions.IgnoreCase)
|| (value.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length <= 6 && !LooksLikeCompanyName(value));
}
private static bool LooksLikeCompanyName(string value)
{
if (string.IsNullOrWhiteSpace(value) || LooksLikeDateRange(value) || LooksLikeUrlOrEmail(value)) return false;
return Regex.IsMatch(value, @"\b(inc|llc|ltd|limited|plc|corp|corporation|company|group|university|college|council|municipality|kommune|bank|studio|agency|institute|hospital|school|technologies|technology|systems|solutions|consulting|consultants|partners|foundation|ministry|government)\b", RegexOptions.IgnoreCase)
|| value.Contains('&')
|| Regex.IsMatch(value, @"\b[A-Z]{2,}\b");
}
private static bool LooksLikeSkillToken(string value)
{
var normalized = TrimOrNull(value)?.Trim('.', ' ');
return normalized is not null && NonLocationTokens.Contains(normalized);
}
private static bool LooksLikeQualification(string value)
{
return Regex.IsMatch(value, @"\b(level\s*\d+|nvq|btec|gcse|a-?level|diploma|certificate|certification|bachelor(?:'s)?|master(?:'s)?|phd|doctorate|mba|ba|bsc|msc|ma|associate|apprenticeship|degree|ict)\b", RegexOptions.IgnoreCase);
}
private static bool LooksLikeInstitutionName(string value)
{
return Regex.IsMatch(value, @"\b(university|college|school|academy|institute|faculty|campus|council|polytechnic)\b", RegexOptions.IgnoreCase);
}
private static string? NormalizeQualification(string? value)
{
var trimmed = TrimOrNull(value);
if (trimmed is null) return null;
if (LooksLikeDateRange(trimmed) || LooksLikeUrlOrEmail(trimmed) || LooksLikeSectionHeading(trimmed)) return null;
trimmed = Regex.Replace(trimmed, @"\s+", " ").Trim(' ', '|', ';', ':');
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
}
private static string? NormalizeInstitution(string? value)
{
var trimmed = TrimOrNull(value);
if (trimmed is null) return null;
if (LooksLikeDateRange(trimmed) || LooksLikeUrlOrEmail(trimmed) || LooksLikeSectionHeading(trimmed)) return null;
trimmed = Regex.Replace(trimmed, @"\s+", " ").Trim(' ', '|', ';', ':');
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
}
private static string? NormalizeQualificationLevel(string? explicitValue, string? qualificationText)
{
var candidate = TrimOrNull(explicitValue) ?? TrimOrNull(qualificationText);
if (candidate is null) return null;
if (Regex.IsMatch(candidate, @"\b(phd|doctorate|dphil)\b", RegexOptions.IgnoreCase)) return "PhD";
if (Regex.IsMatch(candidate, @"\b(master(?:'s)?|msc|m\.sc|ma|m\.a|mba|meng|meng)\b", RegexOptions.IgnoreCase)) return "Master";
if (Regex.IsMatch(candidate, @"\b(bachelor(?:'s)?|bsc|b\.sc|ba|b\.a|beng|llb|undergraduate degree)\b", RegexOptions.IgnoreCase)) return "Bachelor";
if (Regex.IsMatch(candidate, @"\b(diploma|certificate|certification|nvq|btec|level\s*\d+|apprenticeship|associate degree)\b", RegexOptions.IgnoreCase)) return "Diploma/Certificate";
if (Regex.IsMatch(candidate, @"\b(gcse|a-?level|secondary|high school|gymnasium)\b", RegexOptions.IgnoreCase)) return "Secondary";
return "Other";
}
private static StructuredCvEducation NormalizeEducation(StructuredCvEducation? education)
{
education ??= new StructuredCvEducation();
education.Qualification = TrimOrNull(education.Qualification);
education.Institution = TrimOrNull(education.Institution);
education.Location = TrimOrNull(education.Location);
education.Start = TrimOrNull(education.Start);
education.End = TrimOrNull(education.End);
education.Qualification = NormalizeQualification(education.Qualification);
education.QualificationLevel = NormalizeQualificationLevel(education.QualificationLevel, education.Qualification);
education.Institution = NormalizeInstitution(education.Institution);
education.Location = NormalizeLocationValue(education.Location);
education.Start = NormalizeDateValue(education.Start);
education.End = NormalizeDateValue(education.End);
education.Details = CleanList(education.Details);
if (!string.IsNullOrWhiteSpace(education.Qualification) && !string.IsNullOrWhiteSpace(education.Institution))
{
var qualificationLooksInstitutional = LooksLikeInstitutionName(education.Qualification) && !LooksLikeQualification(education.Qualification);
var institutionLooksQualification = LooksLikeQualification(education.Institution) && !LooksLikeInstitutionName(education.Institution);
if (qualificationLooksInstitutional && institutionLooksQualification)
{
(education.Qualification, education.Institution) = (education.Institution, education.Qualification);
education.QualificationLevel = NormalizeQualificationLevel(education.QualificationLevel, education.Qualification);
}
}
return education;
}
private static StructuredCvCertification NormalizeCertification(StructuredCvCertification? certification)
{
certification ??= new StructuredCvCertification();
certification.Name = NormalizeQualification(certification.Name);
certification.Issuer = NormalizeInstitution(certification.Issuer);
certification.Location = NormalizeLocationValue(certification.Location);
certification.Date = NormalizeDateValue(certification.Date);
certification.Details = CleanList(certification.Details);
return certification;
}
private static StructuredCvProject NormalizeProject(StructuredCvProject? project)
{
project ??= new StructuredCvProject();
project.Name = NormalizeQualification(project.Name);
project.Role = NormalizeJobTitle(project.Role);
project.Location = NormalizeLocationValue(project.Location);
project.Start = NormalizeDateValue(project.Start);
project.End = NormalizeDateValue(project.End);
project.Bullets = CleanList(project.Bullets)
.Select(NormalizeBullet)
.Where(bullet => bullet is not null)
.Select(bullet => bullet!)
.ToList();
project.Skills = CleanList(project.Skills);
return project;
}
private static StructuredCvLanguage NormalizeLanguage(StructuredCvLanguage? language)
{
language ??= new StructuredCvLanguage();
language.Name = TrimOrNull(language.Name);
language.Level = TrimOrNull(language.Level);
var originalName = TrimOrNull(language.Name);
var normalizedName = HumanLanguageCatalog.NormalizeLanguageName(originalName);
var normalizedLevel = HumanLanguageCatalog.ExtractLevel(language.Level) ?? HumanLanguageCatalog.ExtractLevel(originalName);
language.Name = normalizedName is not null && normalizedLevel is not null ? normalizedName : null;
language.Level = normalizedLevel;
language.Notes = TrimOrNull(language.Notes);
return language;
}
@@ -267,12 +579,42 @@ public static class StructuredCvProfileJson
AddIf(lines, $"### {education.Qualification}".Trim());
var meta = string.Join(" | ", new[] { education.Institution, education.Location, FormatDateRange(education.Start, education.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)));
AddIf(lines, meta);
if (!string.IsNullOrWhiteSpace(education.QualificationLevel)) AddIf(lines, $"Level: {education.QualificationLevel}");
lines.AddRange(education.Details.Select(detail => $"- {detail}"));
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(string.Empty);
}
AddSectionIfAny(sections, "Education", lines);
}
if (profile.Certifications.Count > 0)
{
var lines = new List<string>();
foreach (var certification in profile.Certifications)
{
AddIf(lines, $"### {certification.Name}".Trim());
var meta = string.Join(" | ", new[] { certification.Issuer, certification.Location, certification.Date }.Where(value => !string.IsNullOrWhiteSpace(value)));
AddIf(lines, meta);
lines.AddRange(certification.Details.Select(detail => $"- {detail}"));
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(string.Empty);
}
AddSectionIfAny(sections, "Certifications", lines);
}
if (profile.Projects.Count > 0)
{
var lines = new List<string>();
foreach (var project in profile.Projects)
{
AddIf(lines, $"### {project.Name}".Trim());
var meta = string.Join(" | ", new[] { project.Role, project.Location, FormatDateRange(project.Start, project.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)));
AddIf(lines, meta);
lines.AddRange(project.Bullets.Select(bullet => $"- {bullet}"));
if (project.Skills.Count > 0) AddIf(lines, $"Skills: {string.Join(", ", project.Skills)}");
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(string.Empty);
}
AddSectionIfAny(sections, "Projects", lines);
}
AddSectionIfAny(sections, "Skills", profile.Skills);
if (profile.Languages.Count > 0)
@@ -328,10 +670,62 @@ public static class StructuredCvProfileJson
}
}
var leftovers = lines.Where(line => !line.Contains('@') && !line.Contains("linkedin", StringComparison.OrdinalIgnoreCase) && !line.Equals(contact.Website, StringComparison.OrdinalIgnoreCase) && !line.Equals(contact.Phone, StringComparison.OrdinalIgnoreCase)).ToList();
if (leftovers.Count > 0) contact.FullName ??= leftovers[0].Trim();
if (leftovers.Count > 1) contact.Headline ??= leftovers[1].Trim();
if (leftovers.Count > 2) contact.Location ??= leftovers[2].Trim();
var leftovers = lines.Where(line => !line.Contains('@')
&& !line.Contains("linkedin", StringComparison.OrdinalIgnoreCase)
&& !line.Equals(contact.Website, StringComparison.OrdinalIgnoreCase)
&& !line.Equals(contact.Phone, StringComparison.OrdinalIgnoreCase))
.ToList();
var plausibleName = leftovers.FirstOrDefault(line => LooksLikePersonName(line));
contact.FullName ??= plausibleName?.Trim();
contact.FullName ??= GuessNameFromLinkedIn(contact.LinkedIn);
contact.FullName ??= GuessNameFromEmail(contact.Email);
var remaining = leftovers.Where(line => !string.Equals(line, contact.FullName, StringComparison.OrdinalIgnoreCase)).ToList();
var addressLike = remaining.Where(LooksLikeAddressish).ToList();
if (remaining.Count > 1 && !LooksLikeAddressish(remaining[0])) contact.Headline ??= remaining[0].Trim();
contact.Location ??= addressLike.LastOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(contact.Location))
{
var nonHeadline = remaining.Where(line => !string.Equals(line, contact.Headline, StringComparison.OrdinalIgnoreCase)).ToList();
contact.Location ??= nonHeadline.LastOrDefault()?.Trim();
}
}
private static bool LooksLikeAddressish(string value)
{
return value.Any(char.IsDigit)
|| Regex.IsMatch(value, @"\b(street|st\.?|road|rd\.?|avenue|ave\.?|suite|city|london|new york|oslo|uk|ny)\b", RegexOptions.IgnoreCase);
}
private static bool LooksLikePersonName(string value)
{
return Regex.IsMatch(value.Trim(), @"^[A-Z][A-Za-z'`.-]+(?:\s+[A-Z][A-Za-z'`.-]+){1,3}$");
}
private static string? GuessNameFromLinkedIn(string? linkedIn)
{
var value = TrimOrNull(linkedIn);
if (value is null) return null;
var match = Regex.Match(value, @"linkedin\.com/(?:in|pub)/(?<slug>[a-z0-9._-]+)", RegexOptions.IgnoreCase);
if (!match.Success) return null;
var parts = Regex.Split(match.Groups["slug"].Value, @"[._-]+")
.Where(part => !string.IsNullOrWhiteSpace(part) && part.All(ch => char.IsLetter(ch)))
.Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant())
.ToList();
return parts.Count >= 2 ? string.Join(" ", parts) : null;
}
private static string? GuessNameFromEmail(string? email)
{
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@')) return null;
var local = email[..email.IndexOf('@')].Trim();
if (string.IsNullOrWhiteSpace(local)) return null;
var parts = Regex.Split(local, @"[._-]+", RegexOptions.None)
.Where(part => !string.IsNullOrWhiteSpace(part))
.Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant())
.ToList();
return parts.Count >= 2 ? string.Join(" ", parts) : null;
}
private static List<StructuredCvLanguage> ParseLanguages(string content)
@@ -339,15 +733,16 @@ public static class StructuredCvProfileJson
return SplitList(content)
.Select(item =>
{
var name = item;
var normalized = item.Trim();
var name = normalized;
string? level = null;
string? notes = null;
var colonIndex = item.IndexOf(':');
var colonIndex = normalized.IndexOf(':');
if (colonIndex > 0)
{
name = item[..colonIndex].Trim();
var remainder = item[(colonIndex + 1)..].Trim();
name = normalized[..colonIndex].Trim();
var remainder = normalized[(colonIndex + 1)..].Trim();
var noteMatch = Regex.Match(remainder, @"^(.*?)\s*\((.*?)\)$");
if (noteMatch.Success)
{
@@ -359,8 +754,32 @@ public static class StructuredCvProfileJson
level = remainder.NullIfWhitespace();
}
}
else
{
var dashMatch = Regex.Match(normalized, @"^(?<name>[\p{L}][\p{L}\s-]+?)\s*[-]\s*(?<level>.+)$");
if (dashMatch.Success)
{
name = dashMatch.Groups["name"].Value.Trim();
level = dashMatch.Groups["level"].Value.Trim();
}
else
{
var parenMatch = Regex.Match(normalized, @"^(?<name>[\p{L}][\p{L}\s-]+?)\s*\((?<level>.+)\)$");
if (parenMatch.Success)
{
name = parenMatch.Groups["name"].Value.Trim();
level = parenMatch.Groups["level"].Value.Trim();
}
}
}
return new StructuredCvLanguage { Name = name.NullIfWhitespace(), Level = level, Notes = notes };
var normalizedLevel = HumanLanguageCatalog.ExtractLevel(level) ?? HumanLanguageCatalog.ExtractLevel(normalized);
return new StructuredCvLanguage
{
Name = normalizedLevel is not null ? HumanLanguageCatalog.NormalizeLanguageName(name) : null,
Level = normalizedLevel,
Notes = notes,
};
})
.Where(language => !string.IsNullOrWhiteSpace(language.Name))
.ToList();
@@ -381,17 +800,30 @@ public static class StructuredCvProfileJson
if (lines[0].StartsWith("###", StringComparison.Ordinal)) lines[0] = lines[0].TrimStart('#', ' ');
job.Title = lines[0].NullIfWhitespace();
var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line)).ToList();
var dateValue = metadata.Select(line => Regex.Match(line, @"(?:(?:\w+\s+)?\d{4}|Present|Current)(?:\s*[-]\s*(?:(?:\w+\s+)?\d{4}|Present|Current))?", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null);
if (!string.IsNullOrWhiteSpace(dateValue))
var titleDateMatch = Regex.Match(job.Title ?? string.Empty, @"(?<title>.+?)\s*[-]\s*(?<start>(?:\d{1,2}/)?\d{4})\s*(?:to|[-])\s*(?<end>(?:\d{1,2}/)?\d{4}|Present|Current)$", RegexOptions.IgnoreCase);
if (titleDateMatch.Success)
{
var parts = Regex.Split(dateValue, "\\s*[-]\\s*");
job.Title = titleDateMatch.Groups["title"].Value.NullIfWhitespace();
job.Start = titleDateMatch.Groups["start"].Value.NullIfWhitespace();
job.End = titleDateMatch.Groups["end"].Value.NullIfWhitespace();
job.IsCurrent = string.Equals(job.End, "present", StringComparison.OrdinalIgnoreCase) || string.Equals(job.End, "current", StringComparison.OrdinalIgnoreCase);
}
var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line)).ToList();
var dateValue = metadata.Select(line => Regex.Match(line, @"(?:(?:\d{1,2}/)?\d{4}|Present|Current)(?:\s*(?:[-]|to)\s*(?:(?:\d{1,2}/)?\d{4}|Present|Current))?", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null);
if (!string.IsNullOrWhiteSpace(dateValue) && string.IsNullOrWhiteSpace(job.Start))
{
var parts = Regex.Split(dateValue, "\\s*(?:[-]|to)\\s*");
job.Start = parts.FirstOrDefault().NullIfWhitespace();
job.End = parts.Skip(1).FirstOrDefault().NullIfWhitespace();
job.IsCurrent = string.Equals(job.End, "present", StringComparison.OrdinalIgnoreCase) || string.Equals(job.End, "current", StringComparison.OrdinalIgnoreCase);
}
var metadataWithoutDates = metadata.Select(line => line.Replace(dateValue ?? string.Empty, string.Empty).Trim(' ', '|', ',', '-')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
var metadataWithoutDates = metadata
.Select(line => string.IsNullOrWhiteSpace(dateValue) ? line : line.Replace(dateValue, string.Empty))
.Select(line => line.Trim(' ', '|', ',', '-'))
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
if (metadataWithoutDates.Count > 0) job.Company = metadataWithoutDates[0].NullIfWhitespace();
if (metadataWithoutDates.Count > 1) job.Location = metadataWithoutDates[1].NullIfWhitespace();
@@ -400,10 +832,32 @@ public static class StructuredCvProfileJson
.Where(line => line.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase))
.SelectMany(line => SplitList(line[(line.IndexOf(':') + 1)..]))
.ToList();
if (job.Skills.Count == 0)
{
job.Skills = job.Bullets
.SelectMany(ExtractSkillsFromBullet)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
return string.IsNullOrWhiteSpace(job.Title) && string.IsNullOrWhiteSpace(job.Company) && job.Bullets.Count == 0 ? null : job;
}
private static IEnumerable<string> ExtractSkillsFromBullet(string bullet)
{
if (string.IsNullOrWhiteSpace(bullet)) yield break;
var usingMatch = Regex.Match(bullet, @"\b(?:using|including|with|technologies?:|tools?:)\s+(?<skills>.+)$", RegexOptions.IgnoreCase);
if (usingMatch.Success)
{
foreach (var item in SplitList(usingMatch.Groups["skills"].Value))
{
var trimmed = item.Trim().TrimEnd('.');
if (trimmed.Length >= 2 && trimmed.Length <= 40) yield return trimmed;
}
}
}
private static List<StructuredCvEducation> ParseEducation(string content)
{
var blocks = SplitBlocks(content);
@@ -428,14 +882,85 @@ public static class StructuredCvProfileJson
education.End = parts.Skip(1).FirstOrDefault().NullIfWhitespace();
}
var metadataWithoutDates = metadata.Select(line => line.Replace(dateValue ?? string.Empty, string.Empty).Trim(' ', '|', ',', '-')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
var metadataWithoutDates = metadata
.Select(line => string.IsNullOrWhiteSpace(dateValue) ? line : line.Replace(dateValue, string.Empty))
.Select(line => line.Trim(' ', '|', ',', '-'))
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
if (metadataWithoutDates.Count > 0) education.Institution = metadataWithoutDates[0].NullIfWhitespace();
if (metadataWithoutDates.Count > 1) education.Location = metadataWithoutDates[1].NullIfWhitespace();
education.Details = lines.Skip(1).Where(IsBullet).Select(line => line.Trim().TrimStart('-', '•', '*', ' ')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
education.QualificationLevel = NormalizeQualificationLevel(null, education.Qualification);
return string.IsNullOrWhiteSpace(education.Qualification) && string.IsNullOrWhiteSpace(education.Institution) && education.Details.Count == 0 ? null : education;
}
private static List<StructuredCvCertification> ParseCertifications(string content)
{
var blocks = SplitBlocks(content);
return blocks.Select(ParseCertificationBlock).Where(certification => certification is not null).Select(certification => certification!).ToList();
}
private static StructuredCvCertification? ParseCertificationBlock(string block)
{
var lines = block.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if (lines.Count == 0) return null;
var certification = new StructuredCvCertification();
if (lines[0].StartsWith("###", StringComparison.Ordinal)) lines[0] = lines[0].TrimStart('#', ' ');
certification.Name = lines[0].NullIfWhitespace();
var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line)).ToList();
certification.Date = metadata.Select(line => Regex.Match(line, @"(?:(?:\w+\s+)?\d{4}|Present|Current)", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null);
var metadataWithoutDates = metadata
.Select(line => string.IsNullOrWhiteSpace(certification.Date) ? line : line.Replace(certification.Date, string.Empty))
.Select(line => line.Trim(' ', '|', ',', '-'))
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
if (metadataWithoutDates.Count > 0) certification.Issuer = metadataWithoutDates[0].NullIfWhitespace();
if (metadataWithoutDates.Count > 1) certification.Location = metadataWithoutDates[1].NullIfWhitespace();
certification.Details = lines.Skip(1).Where(IsBullet).Select(line => line.Trim().TrimStart('-', '•', '*', ' ')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
return string.IsNullOrWhiteSpace(certification.Name) && string.IsNullOrWhiteSpace(certification.Issuer) ? null : certification;
}
private static List<StructuredCvProject> ParseProjects(string content)
{
var blocks = SplitBlocks(content);
return blocks.Select(ParseProjectBlock).Where(project => project is not null).Select(project => project!).ToList();
}
private static StructuredCvProject? ParseProjectBlock(string block)
{
var lines = block.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if (lines.Count == 0) return null;
var project = new StructuredCvProject();
if (lines[0].StartsWith("###", StringComparison.Ordinal)) lines[0] = lines[0].TrimStart('#', ' ');
project.Name = lines[0].NullIfWhitespace();
var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line) && !line.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase)).ToList();
var dateValue = metadata.Select(line => Regex.Match(line, @"(?:(?:\w+\s+)?\d{4}|Present|Current)(?:\s*[-]\s*(?:(?:\w+\s+)?\d{4}|Present|Current))?", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null);
if (!string.IsNullOrWhiteSpace(dateValue))
{
var parts = Regex.Split(dateValue, "\\s*[-]\\s*");
project.Start = parts.FirstOrDefault().NullIfWhitespace();
project.End = parts.Skip(1).FirstOrDefault().NullIfWhitespace();
}
var metadataWithoutDates = metadata
.Select(line => string.IsNullOrWhiteSpace(dateValue) ? line : line.Replace(dateValue, string.Empty))
.Select(line => line.Trim(' ', '|', ',', '-'))
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
if (metadataWithoutDates.Count > 0) project.Role = metadataWithoutDates[0].NullIfWhitespace();
if (metadataWithoutDates.Count > 1) project.Location = metadataWithoutDates[1].NullIfWhitespace();
project.Bullets = lines.Where(IsBullet).Select(line => line.Trim().TrimStart('-', '•', '*', ' ')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
project.Skills = lines
.Where(line => line.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase))
.SelectMany(line => SplitList(line[(line.IndexOf(':') + 1)..]))
.ToList();
return string.IsNullOrWhiteSpace(project.Name) && string.IsNullOrWhiteSpace(project.Role) && project.Bullets.Count == 0 ? null : project;
}
private static List<string> SplitBlocks(string content)
{
var normalized = content.Replace("\r\n", "\n").Trim();
+1
View File
@@ -47,6 +47,7 @@ public sealed class TailoredCvExperienceItem
public sealed class TailoredCvEducationItem
{
public string? Qualification { get; set; }
public string? QualificationLevel { get; set; }
public string? Institution { get; set; }
public string? Location { get; set; }
public string? Start { get; set; }
+2 -1
View File
@@ -128,7 +128,7 @@ public static class TailoredCvDraftJson
var block = new List<string>();
foreach (var item in normalized.Education)
{
AddLine(block, item.Qualification);
AddLine(block, string.IsNullOrWhiteSpace(item.QualificationLevel) ? item.Qualification : $"{item.Qualification} ({item.QualificationLevel})");
var meta = string.Join(" | ", new[]
{
item.Institution,
@@ -170,6 +170,7 @@ public static class TailoredCvDraftJson
{
item ??= new TailoredCvEducationItem();
item.Qualification = TrimOrNull(item.Qualification);
item.QualificationLevel = TrimOrNull(item.QualificationLevel);
item.Institution = TrimOrNull(item.Institution);
item.Location = TrimOrNull(item.Location);
item.Start = TrimOrNull(item.Start);
+8 -2
View File
@@ -25,7 +25,7 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re
## Quickstart (Docker)
This runs: frontend (nginx), backend API, and the AI service.
This runs: frontend (nginx), backend API, the local AI service, and an Ollama container for hybrid CV block classification.
1) Create a `.env` file next to `docker-compose.yml` (you can start from `.env.example`).
@@ -108,9 +108,15 @@ The API calls a local FastAPI service to generate summaries. If its not runni
With Docker (recommended):
```bash
docker compose up --build ai-service
# One command for local Ollama startup + pull + AI-service restart
OLLAMA_MODEL=qwen2.5:7b ./scripts/start-ollama-cv.sh
# Then start the rest of the app if needed
docker compose up --build -d backend frontend
```
The first Ollama startup is usually quick, but the first model pull and first generation can take a while. After the model is cached in the `ollama_data` volume, later restarts are much faster.
Or run directly from `tools/summarizer/` (see `tools/summarizer/README.md`).
## Configuration
+76
View File
@@ -0,0 +1,76 @@
# Smart Gmail Job Correspondence Integration Progress
## Branch
- main
## Status
- Core Phase 1 Gmail correspondence feature is now implemented in code.
- Remaining gap is deployment/runtime rollout on the live host, not missing product logic in this repo.
## Completed
### Foundation
- Gmail OAuth connect/disconnect/status flow preserved.
- Durable Gmail sync-state fields added and surfaced from `GET /api/gmail/status`.
- Per-job correspondence UI shows Gmail sync diagnostics.
### Ingestion and storage
- Imported Gmail correspondence stores:
- direction
- Gmail labels JSON
- attachment metadata JSON
- Gmail payload parsing extracts labels and attachment metadata.
- Message-level deduplication remains in place.
- Linked-thread refresh continues to import only new thread messages.
### Matching and routing
- Deterministic scoring extracted to `JobTrackerApi/Services/GmailJobMatchingService.cs`.
- Review queue backend exists at `GET /api/gmail/review-candidates`.
- Review decisions persist through `POST /api/gmail/review-decision`.
- Manual sync now exists at `POST /api/gmail/manual-sync`.
- Manual sync applies a bounded historical window and excludes spam/trash by default.
- High-confidence matches now auto-link during manual sync.
- Medium-confidence matches remain in review.
- Low-confidence job-like threads can be marked as suggested jobs.
- Suggested-job surfaces now exist via:
- `GET /api/gmail/suggested-jobs`
- `POST /api/gmail/create-suggested-job`
### Correspondence UX
- Global inbox exists at `/correspondence`.
- Gmail review page exists at `/correspondence/review`.
- Review page now supports:
- manual sync
- routing filters
- review notes
- link/review/reject/suggested actions
- create-job flow from suggested Gmail threads
- Per-job correspondence workspace now supports:
- linked-thread refresh
- unlink thread from current job
- move/relink thread to another existing job
- Backend relink/unlink endpoints now exist:
- `POST /api/gmail/relink-thread`
- `POST /api/gmail/unlink-thread`
### Phase 2 prep
- Future seam remains in place at `JobTrackerApi/Services/GmailCorrespondenceEnrichment.cs`.
- Design doc remains in place at `docs/gmail-correspondence-phase1.md`.
### Deployment hardening
- Added deploy smoke-check logic to `deploy/deploy.sh`.
- Deploy now fails if `${APP_PUBLIC_BASE_URL}/api/auth/config` returns HTML or non-JSON instead of backend auth config JSON.
## Verification completed
- `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests /p:DisableSourceControlManagerQueries=true`
- `cd job-tracker-ui && CI=true ./node_modules/.bin/react-scripts test --runInBand --watch=false src/correspondence-gmail-import.test.tsx src/gmail-review-page.test.tsx src/correspondence-inbox-page.test.tsx`
- `dotnet build './Job tracker.sln' -c Release`
## Runtime note
- Live host check shows `https://jobs.cesnimda.uk/api/auth/config` currently returns the frontend HTML shell (`x-powered-by: Express`) instead of backend JSON.
- That is a deployment/proxy mismatch outside the app code in this checkout.
- The new deploy smoke-check was added so future deploys fail fast on that condition.
## Resume notes
- If the live site still shows 404s for `/api/...`, the running service is not the repos Dockerized frontend+backend path.
- The CRA/Express-style live response and websocket attempts to `:3000/ws` suggest an old dev-style frontend process or wrong reverse-proxy target is still serving the domain.

Some files were not shown because too many files have changed in this diff Show More