Compare commits

...

105 Commits

Author SHA1 Message Date
cesnimda 9febc2b22f refactor(gmail): route controller read paths through IEmailProvider
CI and Deploy / test (pull_request) Successful in 1m58s
CI and Deploy / deploy (pull_request) Has been skipped
GmailController now resolves the "gmail" provider from IEmailProviderRegistry and
uses the provider-neutral seam for its read paths — message search (SearchAsync)
and thread listing (ListThreadMessagesAsync) across ImportThread, RelinkThread,
CreateSuggestedJob, RefreshLinkedThreads and the messages endpoint. OAuth
(connect/callback), connection status and Gmail-specific candidate ranking stay
on IGmailOAuthService until they are generalised.

An optional constructor param keeps direct construction (tests) working via a
fallback single-Gmail registry, so the mocked Gmail service is exercised through
GmailProvider. Behaviour is preserved (neutral DTOs mirror the Gmail shapes).

This makes the seam a real consumer and sets up MicrosoftGraphProvider /
ImapProvider / a manual free-text provider to slot in next.

Build clean; backend suite 135/135 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:11:10 +02:00
cesnimda 35eaef9dee Merge pull request 'ci: resilient .NET setup (retry dotnet-install, drop flaky setup-dotnet)' (#8) from ci/resilient-dotnet-setup into main
CI and Deploy / test (push) Successful in 1m59s
CI and Deploy / deploy (push) Successful in 21s
2026-07-11 13:10:57 +02:00
cesnimda fc356012e6 ci: install .NET via retrying dotnet-install.sh instead of setup-dotnet
CI and Deploy / test (pull_request) Successful in 2m5s
CI and Deploy / deploy (pull_request) Has been skipped
The single self-hosted act_runner intermittently fails actions/setup-dotnet:
a partial extraction sticks in the shared tool-cache (tar: Cannot open: File
exists) or the SDK tarball download corrupts. Install into a clean private
$HOME/.dotnet via dotnet-install.sh with a rm -rf + retry-once, matching the
npm ci and NuGet publish retries already in this pipeline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:02:37 +02:00
cesnimda e90835b51e Merge pull request 'fix(deploy): retry publish after clearing NuGet caches on NU3008' (#5) from fix/deploy-nuget-integrity into main
CI and Deploy / test (push) Successful in 2m10s
CI and Deploy / deploy (push) Successful in 2m46s
2026-07-06 01:12:06 +02:00
cesnimda 4b38f7c164 ci: retry npm ci once on the runner's intermittent SIGSEGV
CI and Deploy / test (pull_request) Successful in 2m12s
CI and Deploy / deploy (pull_request) Has been skipped
The frontend deps step occasionally crashes with "Segmentation fault (core
dumped)" (exit 139) during `npm ci` — a memory/native flake on the act_runner,
unrelated to the change under test (it failed the deploy-fix PR whose only change
is the Dockerfile). Retry once with a clean node_modules before failing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 01:08:00 +02:00
cesnimda 63e0300788 fix(deploy): retry publish after clearing NuGet caches on NU3008
CI and Deploy / test (pull_request) Failing after 2m9s
CI and Deploy / deploy (pull_request) Has been skipped
The prod deploy failed restoring a transitive package
(Microsoft.CodeAnalysis.Workspaces.Common) with NU3008 "package integrity check
failed / has changed since it was signed" — a transient corrupted download on the
build host, not a code change. Wrap the backend `dotnet publish` so that on any
failure it clears all NuGet caches and retries once, re-downloading the package
fresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:51:29 +02:00
cesnimda 1badff1437 Merge pull request 'perf(gmail): batch CreateSuggestedJob N+1 + landing product preview' (#4) from feat/email-provider-migration into main
CI and Deploy / test (push) Successful in 2m6s
CI and Deploy / deploy (push) Failing after 1m6s
2026-07-05 21:34:34 +02:00
cesnimda aa19edbc49 feat(ui): add product preview (mockups) to landing page
CI and Deploy / test (pull_request) Successful in 2m11s
CI and Deploy / deploy (pull_request) Has been skipped
"See it in action" section showing the dashboard, pipeline board and per-job
workspace. Uses the design-mockup SVGs (small + crisp, ~27KB total) served from
public/mockups/, honestly captioned "Interface preview". Verified live: all three
render at "/".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:23:51 +02:00
cesnimda 96b9489d49 perf(gmail): batch the duplicate-message check in CreateSuggestedJob
CreateSuggestedJob ran one AnyAsync per message in the thread to decide
imported-vs-skip — an N+1 that scales with thread length. Replace it with a
single query that loads the already-imported ExternalMessageIds for the job,
then check in memory (identical skip/import behaviour), mirroring the batched
pattern RelinkThread already uses.

Build clean; backend suite 135/135 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:21:36 +02:00
cesnimda af420a7ad1 Merge pull request 'perf(analytics): project minimal columns in GetStats/GetAnalyticsOverview' (#3) from perf/wave1-perf into main
CI and Deploy / test (push) Successful in 2m14s
CI and Deploy / deploy (push) Failing after 1m0s
2026-07-05 21:18:55 +02:00
cesnimda 3d5ab8f32c feat(ui): add pricing section to landing page
CI and Deploy / test (pull_request) Successful in 2m8s
CI and Deploy / deploy (pull_request) Has been skipped
Three honest tiers (Free / Pro £9-mo / Bring-your-own-key £3-mo) billed monthly
or yearly — never by the week (the anti-Teal positioning from
docs/remaster/RESEARCH_COMPETITORS.md), with a "Most popular" highlight and the
assistive-not-autonomous trust note. Prices are indicative placeholders for the
SaaS direction.

Verified live: pricing section + all three tiers render at "/".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:45:20 +02:00
cesnimda c53d7978bb feat(email): introduce IEmailProvider seam + GmailProvider adapter
First slice toward multi-provider email (Gmail + Microsoft Graph + IMAP +
manual/free-text, per docs/remaster/PRODUCT_DIRECTION.md). Adds a provider-
neutral contract (search / list-thread / get-message / get-connection) with
neutral DTOs, a registry to resolve providers by key, and a GmailProvider that
adapts the existing IGmailOAuthService to it.

No behaviour change: the seam is registered in DI but not yet consumed. Follow-up
slices migrate GmailController's read paths onto IEmailProvider (folding in the
N+1 fixes) and add MicrosoftGraphProvider / ImapProvider / a manual provider.

Build clean; backend suite 135/135 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:42:46 +02:00
cesnimda 919f61dde6 feat(ui): public marketing landing page at "/" for logged-out visitors
CI and Deploy / test (pull_request) Successful in 2m7s
CI and Deploy / deploy (pull_request) Has been skipped
Add a LandingPage (hero + features + how-it-works + CTAs) served at "/" so
visitors learn about the product before signing in — matching the JobTrack
mockups (indigo/cyan, dark hero + light sections). If the visitor already has a
session, LandingPage redirects into the app (/jobs); otherwise it shows the
marketing page with "Sign in" CTAs. The "/" route is public (outside the
auth-gated Shell), so logged-out users no longer bounce straight to /login.

Verified live: renders at "/" with headline, feature grid, how-it-works steps
and CTAs; no console errors; type-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:25:43 +02:00
cesnimda 0ca8c95372 merge: reconcile perf/wave1-perf with main (Wave 0 features)
CI and Deploy / test (pull_request) Successful in 2m13s
CI and Deploy / deploy (pull_request) Has been skipped
Resolve conflicts from main's Wave 0 (PR #1) landing after this branch was cut:

- useViewResource.ts: main's e352aae already fixes the render loop the same way
  (load in a ref, dropped from deps) — took main's canonical version. My
  independent fix is superseded (my branch predated e352aae, which is why the
  loop reproduced live).
- JobApplicationsController.cs: keep BOTH main's IJobCvMatchService and my
  AnalyticsService (ctor gets both optional params). GetAnalyticsOverview stays
  delegated to AnalyticsService.
- Fold main's H3 additions into the extracted AnalyticsService: pipeline-driven
  funnel (JobPipeline.Normalize/Stages) + time-in-stage (StageAnalytics) and add
  StageDurationDto + TimeInStage to Models/AnalyticsDtos.cs, preserving the API
  contract the frontend expects.

Build clean; backend suite 135/135 green.
2026-07-05 20:16:40 +02:00
cesnimda b8f8569e6e fix(hooks): stop infinite render loop in useViewResource
CI and Deploy / test (pull_request) Successful in 2m0s
CI and Deploy / deploy (pull_request) Has been skipped
useViewResource built `reload` with `load` in its useCallback deps, and the
fetch effect depended on `reload`. Callers routinely pass an inline `load`
closure (e.g. JobTable), so `load` — and therefore `reload` and the effect —
changed every render, calling setState and re-rendering: an unbounded
"Maximum update depth exceeded" loop that froze the renderer on /jobs and every
other list view (DashboardView, RemindersView, CompaniesTable).

Fix: hold `load` in a ref (like the existing hasLoadedRef) and drop it from the
dependency arrays. Re-fetching is still driven by `deps`/`enabled`; the ref
always points at the latest closure. No API/behaviour change for callers.

Runtime-verified live: /jobs went from a render storm (frozen renderer, 100s of
console errors) to 0 errors in a 2s window and a clean render. Suites that drive
JobTable→useViewResource pass in isolation; the remaining full-run flakiness is
pre-existing (state-pollution/timing in the heavy RTL suites, unrelated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:36:55 +02:00
cesnimda 490c5b803e fix(auth): stop infinite /auth/me request loop when logged out
The axios 401 interceptor calls clearAuthClientState() on every 401, which
dispatched "auth-changed"; the App handler re-fetched /auth/me, which 401'd
again → interceptor → clearAuthClientState() → "auth-changed" → ... an unbounded
request storm (observed live: 100+ GET /auth/me and climbing) that ran whenever
the user was logged out (login page, expired session) — burning CPU, network and
battery and flooding the server.

Fix: make clearAuthClientState idempotent — only emit "auth-changed" when it
actually removes a stored user key (a real signed-in→out transition), so
repeated 401s can no longer re-trigger the fetch.

Runtime-verified in a live stack: /auth/me went from 100+ & growing to 0 &
stable. login-page/settings tests green. Documented in
docs/performance/PERFORMANCE_IMPROVEMENTS.md (Phase 3.5 runtime finding).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:13:18 +02:00
cesnimda 39266c0935 docs(perf): memory-leak investigation reports (no leak found)
Evidence-based investigation across every leak vector (timers, listeners, object
URLs, observers, websockets, static server collections, IMemoryCache, Python
caches). Verdict: no confirmed memory leak — the codebase has disciplined
cleanup. One resource-release correctness bug (over-eager blob-URL revocation in
the CV carousel) was found and fixed (eed9b1f).

Adds docs/performance/: MEMORY_LEAK_REPORT.md, ROOT_CAUSE_ANALYSIS.md,
PERFORMANCE_IMPROVEMENTS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:59:28 +02:00
cesnimda eed9b1fa80 fix(profile): revoke CV-preview blob URLs on unmount, not on every change
The PDF-carousel cleanup effect had `[pdfCarousel]` deps, so its cleanup ran on
every carousel change and revoked the *previous* array's object URLs — which are
still referenced by unchanged items in the new array. Building a multi-template
deck therefore left every preview except the last with a revoked (broken) blob
URL. Drop paths are already handled explicitly in savePdfToCarousel (replace)
and resetPdfCarousel (clear), so blanket per-change revocation was both harmful
and redundant.

Fix: track the latest carousel in a ref and revoke outstanding URLs only on
unmount (empty-deps effect). No leak either way — unmount still frees them.

Found during the memory-leak/resource audit; this is a resource-release
correctness bug (over-eager revocation), not a leak. profile-page.test: 5/5
green (with an adequate timeout; the suite's 5s-timeout flakiness is pre-existing
and unrelated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:57:22 +02:00
cesnimda e5e2c65709 feat(ui): adopt JobTrack mockup design tokens (indigo brand, light dashboard)
First UI slice toward the product mockups. Shifts the shared MUI theme to match
the mockup identity without restructuring components:
- Default accent -> indigo #6366f1 (drives primary buttons, active nav, and the
  Applied/Interview status colours). Users with a custom accent keep theirs.
- Light app background -> #F4F6FB with white paper, giving the layered dashboard
  look (cards/inputs sit above a soft grey canvas).
- Global corner radius 8 -> 10 to match the mockups' rounder cards.
- Accent picker: offer indigo/cyan/violet presets first (kept #15803d).

Behaviour-only theme change. Frontend suite: 38/39 pass; the 1 failure
(end-to-end-trust-loop tailored-CV) is pre-existing on this branch (verified by
running it on the clean tree) and unrelated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:13:22 +02:00
cesnimda 2989a6fa2c refactor(analytics): extract AnalyticsService from JobApplicationsController
First Wave 2 (safe refactor) slice. Move the read-only stats/overview aggregation
out of the 3.3k-line JobApplicationsController into a dedicated, injectable
AnalyticsService, and lift its response DTOs (JobStats, FunnelStagePoint,
ResponseRatePoint, CompanyActivityPoint, AnalyticsOverviewDto) into
Models/AnalyticsDtos.cs.

- GetStats: ~44 lines -> 3 (delegates to AnalyticsService.GetStatsAsync).
- GetAnalyticsOverview: ~82 lines -> 3 (delegates to GetAnalyticsOverviewAsync).
- Registered AddScoped<AnalyticsService>(); controller keeps an optional ctor
  param with a `?? new AnalyticsService(db)` fallback so the 6 test sites that
  construct the controller directly keep compiling.
- Logic is byte-identical (same tenant-scoped context, same projections) so
  behaviour is preserved.

Backend suite: 92/92 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:51:08 +02:00
cesnimda 824251d328 feat(ai): provider router (ollama|gemini|groq) for heavy CV calls
The structured /cv/* calls funnel through a provider router so production can
offload a weak local GPU (GTX 1060) to a cloud provider without any .NET change.
Default stays "ollama" (keyless/local) and /summarize remains local distilbart.

- AI_PROVIDER=ollama|gemini|groq dispatch inside _ollama_generate_json/_text
  (entry-point names kept, so no call sites change; Ollama path is byte-identical).
- Gemini (x-goog-api-key header, not URL query) and Groq (OpenAI-compatible
  chat/completions) added via stdlib urllib — zero new dependencies.
- /health reports ai_provider + ai_provider_configured.
- Keys read from env only; never logged/committed.
- Compose + .env.example pass AI_PROVIDER/GEMINI_*/GROQ_* through.

Tests: 11 passed (default Ollama unchanged, Gemini/Groq dispatch, missing-key 503,
health reports provider).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:40:55 +02:00
cesnimda b8ec268736 perf(db): add owner-prefixed hot-path indexes on JobApplications
Add composite indexes (OwnerUserId, IsDeleted) — for the tenant-scoped
list/board/stats/analytics queries that all filter !IsDeleted — and
(OwnerUserId, FollowUpAt) for the reminders surface. Every JobApplication
query is scoped by the OwnerUserId global filter first, so owner-prefixed
composites are the useful shape; the pre-existing single OwnerUserId index
is now a redundant prefix but kept to avoid churn.

Status is intentionally excluded: Pomelo maps the unbounded string column to
MariaDB longtext, which cannot be indexed without a prefix length.

Applied via the startup schema reconciler (StartupInitializationExtensions),
which is how this repo actually provisions schema/indexes on both providers
(SQLite: CREATE INDEX IF NOT EXISTS; MariaDB: MySqlIndexExists-guarded CREATE
INDEX) — NOT via EF migrations, whose committed ModelSnapshot is stale.
OnModelCreating also declares the indexes for model consistency.

Backend suite: 92/92 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:30:44 +02:00
cesnimda 6cb593ab5c perf(analytics): project minimal columns in GetStats/GetAnalyticsOverview
Both endpoints materialised full JobApplication rows (GetAnalyticsOverview also
Include-d full Company) purely to aggregate a few fields, dragging the large
Description/TranslatedDescription/TailoredCvText/Notes/CoverLetter blobs over
the wire on every dashboard load. Project to only the columns each aggregation
needs (mirrors the existing GetTagTrends pattern). Behaviour is identical;
aggregation stays in memory over a small per-tenant set.

Backend suite: 92/92 green.

Note: the planned hot-path *index* migration is deferred — the committed EF
ModelSnapshot is stale (21 lines, no entities), so `migrations add` cannot
produce a clean incremental diff. Resyncing the snapshot is a prerequisite and
is tracked as its own task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:20:58 +02:00
cesnimda 31373be841 Merge pull request 'fix(deploy): make bundled Ollama opt-in to avoid duplicate container' (#2) from fix/ollama-no-duplicate into main
CI and Deploy / test (push) Successful in 2m22s
CI and Deploy / deploy (push) Successful in 17s
Reviewed-on: #2
2026-07-03 15:06:23 +02:00
cesnimda e1e508988a fix(deploy): make bundled Ollama opt-in to avoid duplicate container
CI and Deploy / test (pull_request) Successful in 2m4s
CI and Deploy / deploy (pull_request) Has been skipped
The compose file shipped its own ollama service, so 'docker compose pull'
during deploy re-downloaded the Ollama image and a deploy that starts the
AI stack would spin up a second Ollama alongside an existing/shared one.

- ollama service moved behind a 'bundled-ollama' compose profile, so it is
  excluded from the default pull/up (no duplicate, no re-download)
- ai-service no longer depends_on ollama and is documented to point at a
  shared instance via OLLAMA_BASE_URL (e.g. http://<host>:11435)
- deploy.sh no longer names ollama in 'compose up'

To run a self-contained Ollama: docker compose --profile bundled-ollama up

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:52:42 +02:00
cesnimda 316ef9ac1a Merge pull request 'Merge Request — Wave 0 quick wins + Tier-1/Tier-2 features' (#1) from chore/wave0-quick-wins into main
CI and Deploy / test (push) Successful in 2m10s
CI and Deploy / deploy (push) Has been cancelled
Reviewed-on: #1
2026-07-03 11:14:14 +02:00
cesnimda d61dd6310b fix(build): give the frontend build 1GB /dev/shm
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped
CRA's build runs fork-ts-checker in a forked process whose IPC needs
more than Docker's default 64MB /dev/shm; too little segfaults
'npm run build' (RpcIpcMessagePortClosedError / SIGSEGV) with no compile
error. Set shm_size on the frontend image build so production deploys
don't hit this. (CI runners need the same via their container options.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:53:06 +02:00
cesnimda 3bd7b4b7e4 docs: add merge-request summary for review
CI and Deploy / test (pull_request) Successful in 2m10s
CI and Deploy / deploy (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 04:29:27 +02:00
cesnimda 30bb6a942d feat: installable PWA with mobile share-target capture
- Corrected manifest (Jobbjakt branding, matching green theme, maskable
  icons, description/categories/scope/id).
- share_target (GET) maps a shared url/link into the same /?add= capture
  flow the bookmarklet uses, so mobile 'Share -> Jobbjakt' pre-fills Add
  Job.
- resolveCaptureUrl helper (tested) extracts the link from add or from a
  link embedded in shared text; App uses it and strips the params.
- Deliberately no offline service worker: the app deploys frequently and
  an aggressive cache would risk stale builds (documented in README).
- 4 unit tests; build compiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 04:16:44 +02:00
cesnimda fb11469a48 feat: quick-capture bookmarklet
One-click job capture from any posting, reusing the existing
jobimport/preview parser.

- AddJobModal accepts initialUrl and auto-imports once on open
- App reads a /?add=<encoded url> param, opens Add Job pre-filled, and
  strips the param from the address bar
- QuickCaptureCard in Settings offers a draggable bookmarklet (href set
  via ref since React blocks javascript: URLs) plus copyable code
- EN/NB translations; README feature note
- 2 frontend tests; full suite green (22 suites / 50 tests)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 04:12:36 +02:00
cesnimda 5a9245cf74 docs: add security review of session changes (Phase 6)
Scoped security review of Wave 0 + H1-H4: confirms tenant isolation on
new endpoints (query filters + tests), no injection/ReDoS, dev-only
OpenAPI. Flags DataProtection key rotation as the operator action item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:56:58 +02:00
cesnimda 2996441f52 perf: drop duplicated company-existence query in job Create
The create path ran the same Companies.AnyAsync existence check twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:56:06 +02:00
cesnimda bd51c245d3 test(security): lock tenant isolation on match-score and status-suggestion
Cross-user access to the new endpoints returns NotFound (carried by the
JobTrackerContext global query filters). Regression guard for the class
of tenant-leak bugs found in the M013-M015 assessments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:55:12 +02:00
cesnimda a1a3736cc4 feat(ui): human-confirmed status suggestion banner
When a job workspace opens, loads /status-suggestion and shows a
dismissible banner when a recent inbound email implies a status move
("This email looks like a move to Interview"). Applying it PATCHes the
status; nothing changes without the user's click.

- StatusSuggestion type + load-on-open effect + apply handler
- warning-toned banner shown above tab content on any tab
- EN/NB translations; README endpoint docs
- 2 frontend tests; full suite green (21 suites / 48 tests)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:52:57 +02:00
cesnimda ae3505b877 feat: deterministic email-driven status suggestions
New EmailStatusClassifier scans a message subject/body for outcome
signals (interview invite, offer, rejection) and suggests a canonical
pipeline status. Priority-ordered so a rejection that mentions the prior
interview still classifies as Rejected. Deterministic - no AI - so it is
instant, reproducible, and safe.

- GET /api/jobapplications/{id}/status-suggestion reads the job's latest
  inbound correspondence (incl. Gmail imports) and suggests a forward
  status move, suppressed when already in/past that stage
- always human-confirmed via the existing PATCH .../status
- 7 classifier unit tests + 2 endpoint integration tests; backend green (133)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:48:32 +02:00
cesnimda 695fbd6d21 feat(ui): time-in-stage on the dashboard + localized funnel labels
- Adds a 'Median time in stage' block to the conversion-funnel card
  showing median days and active count per stage from the enriched
  analytics-overview endpoint.
- Funnel bar labels and stage names now render through the shared
  pipeline statusLabel (localized; the funnel also now includes Waiting).
- EN/NB translations. Full frontend suite green (20 suites / 46 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:43:17 +02:00
cesnimda 45cbc8b1ab feat: time-in-stage analytics + pipeline-driven funnel
- New pure StageAnalytics.TimeInStage: median days jobs have spent in
  each active pipeline stage (entry time from the last StatusChanged
  event into that stage, else applied date). Closed/success stages
  excluded since 'how long stuck' only applies to actionable stages.
- analytics-overview now derives the funnel from JobPipeline (includes
  the previously-omitted Waiting stage, normalizes legacy spellings) and
  returns TimeInStage.
- 4 unit tests; full backend suite green (124).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:40:41 +02:00
cesnimda 5a306f51a1 refactor(ui): drive status from a single shared pipeline module
Introduces pipeline.ts (mirrors backend JobPipeline) as the one frontend
source of truth for canonical stages, synonym normalization, tone, and
localized labels. Replaces the status list/logic previously duplicated
across KanbanBoard, JobTable, AddJobModal and EditJobDialog.

- KanbanBoard/AddJobModal/EditJobDialog render from PIPELINE_STATUSES
- JobTable uses shared statusTone + statusLabel (status chips now
  localized; NB gets proper labels, English unchanged)
- Edit dialog status dropdown is now localized too
- 5 unit tests; full frontend suite green (19 suites / 41 tests)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:37:14 +02:00
cesnimda bb736d1183 feat: canonical job pipeline as single source of truth
New JobPipeline: ordered canonical stages (Applied, Waiting, Interview,
Offer, Rejected, Ghosted) with category grouping and a Normalize() that
canonicalizes casing and known synonyms (Interviewing->Interview,
declined->Rejected, ...) while preserving unknown custom statuses.

- normalize status on every write path (Create/Update/PATCH status) so
  the stored value stays canonical without destroying custom values
- GET /api/jobapplications/pipeline exposes the ordered stages so the UI
  renders from one source instead of duplicated hardcoded lists
- 14 unit tests; full backend suite green (120)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:31:35 +02:00
cesnimda 209528c8b5 feat(ui): instant match-score panel on the Candidate Fit tab
Adds a MatchScoreCard at the top of the Candidate Fit tab that loads the
deterministic /match-score endpoint independently of the slow AI
narrative, so users see a reproducible score, matched/missing keyword
chips, and per-section coverage immediately.

- MatchScore types + cached, attachment-independent load effect
- graceful 'not enough signal' state
- EN/NB translations
- frontend panel test (matched/missing/section + degraded state)
- backend integration tests for GetMatchScore (happy path + missing CV)
- README endpoint reference

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:24:53 +02:00
cesnimda 3fad43a9e2 feat: deterministic CV-to-job match score endpoint
New JobCvMatchService: a pure, AI-free keyword-coverage scorer that
returns a stable, reproducible 0-100 match score plus matched/missing
keyword lists and per-CV-section coverage. Unlike candidate-fit (AI
narrative), it makes no model calls, so results are instant and
identical for identical inputs - the Jobscan-style differentiator.

- GET /api/jobapplications/{id}/match-score
- keywords = curated SkillTagger tags (high weight) + salient posting
  terms (title terms boosted); word-boundary matching avoids false hits
- section coverage shows where CV evidence is concentrated
- fix(SkillTagger): punctuation-tolerant C#/.NET patterns; the old \b
  boundaries silently missed 'C#,' and '.NET,' everywhere they are used
- 7 unit tests on the pure scorer; full backend suite green (104)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:16:32 +02:00
cesnimda 83e6430a24 feat: structured salary fields (min/max/currency/period)
Adds SalaryMin/SalaryMax/SalaryCurrency/SalaryPeriod alongside the
existing free-text Salary field (kept for back-compat and display).

- JobApplication model + idempotent column bridging for SQLite and MySQL
- Create/Update DTOs with NormalizeSalary (clamps negatives, swaps
  inverted min/max, uppercases currency, whitelists period)
- JobApplicationDto exposes the fields; CSV export gains 4 columns
- UI: add/edit dialogs get min/max/currency/period inputs; job table
  renders a formatted range via shared salary.ts formatter (falls back
  to free-text when structured values are absent)
- EN/NB translations; backend + full frontend suites green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:15:37 +02:00
cesnimda 8f174cb767 feat: dev-only OpenAPI document at /openapi/v1.json
- AddOpenApi/MapOpenApi (anonymous, Development environment only).
- security: mark ProfileCvController.ProcessQueuedRunAsync [NonAction] -
  the controller-level [Route] exposed this background-service hook as a
  routable any-verb endpoint, which also broke OpenAPI generation.

96 endpoint paths documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:00:27 +02:00
cesnimda c41d1e8d0f ci: run the entire frontend test suite instead of a file whitelist
The whitelist silently skipped new suites; two regressions in
non-whitelisted suites reached main unnoticed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:00:27 +02:00
cesnimda 6150e7f19b test(ui): stabilize slow suites and repair stale trust-loop mocks
- Raise testing-library asyncUtilTimeout to 4s and jest timeout to 30s:
  heavy MUI views exceeded the 1s default on slower machines
  (profile-page, daily-control-loop double-mount).
- end-to-end-trust-loop: mock the /tailored-cv-draft endpoint the
  redesigned Tailored CV tab now loads, and assert on the structured
  draft instead of the removed legacy tailoredCvText textarea.

Full suite now green locally: 18/18 suites, 39/39 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:00:26 +02:00
cesnimda 999d6e05e7 feat: automated daily SQLite database backups with retention
New DatabaseBackupHostedService + SqliteDatabaseBackupRunner:
- daily VACUUM INTO snapshot to <Data:Root>/backups (safe with WAL)
- catch-up backup at startup when none exists from the last 24h
- retention pruning (Backups:RetainCount, default 14)
- warns and stays idle on MySQL/MariaDB where external backups apply

Production previously had no automated database backup on Linux
(the /api/backup endpoint is Windows-DPAPI-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:49:58 +02:00
cesnimda e352aaeaac fix(ui): avoid stale load closure in useViewResource reload
Keep the latest load callback in a ref so reload() always invokes the
current fetcher without changing its own identity on every render.
Reduces full-suite test failures from 5 to 3 (remaining are pre-existing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:30:18 +02:00
cesnimda c38295d869 docs: add system overview, product research, and roadmap
Phase 1-3 deliverables: full architecture/security/tech-debt map,
2026 market research with feature matrix, and tiered execution roadmap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:24:03 +02:00
cesnimda 519c32efd7 security: untrack DataProtection keys and runtime exports; remove dead legacy controllers
- git rm --cached on committed DataProtection key XMLs (keys/, JobTrackerApi/keys/)
  and daily export JSON snapshots; extend .gitignore so runtime data
  (keys, exports, CV artifacts/exports/benchmarks) can never be committed again.
- Delete root Controller/ stubs: an early prototype compiled by no project
  (JobTrackerApi excludes them; JobTrackerBackend globs only JobTrackerApi/Controllers).
- NOTE: the removed key XMLs remain in git history; rotating DataProtection
  keys on the server is recommended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:23:50 +02:00
cesnimda aa43ada16a chore: add Windows PowerShell variant of Ollama CV startup script
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:21:47 +02:00
cesnimda 29325a2048 chore(ui): bump nginx base image to 1.29.8-alpine
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:21:46 +02:00
cesnimda 3ef3192e6c docs: record next-session skill suggestions in handoff notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:21:45 +02:00
cesnimda 657cb95a48 Add guided CV builder controls
CI and Deploy / test (push) Failing after 0s
CI and Deploy / deploy (push) Has been skipped
2026-04-20 21:22:57 +02:00
cesnimda eea327e1f6 Turn CV template chooser into visual carousel 2026-04-11 22:45:24 +02:00
cesnimda 54abc9f546 Use Ollama rewrite path for CV generation 2026-04-11 22:26:03 +02:00
cesnimda 591c9b8a64 Clamp AI summarize lengths for CV rewrite 2026-04-11 21:55:51 +02:00
cesnimda 534534b333 Harden CV rewrite diagnostics and preview PDFs 2026-04-11 21:36:45 +02:00
cesnimda fcccecefa3 Fix startup admin seeding connection scope 2026-04-11 18:27:33 +02:00
cesnimda 48cd83b442 Clean error alerts and harden startup migration 2026-04-11 18:07:20 +02:00
cesnimda b52371ea79 Fix backend deployment Playwright restore issue 2026-04-11 17:45:51 +02:00
cesnimda cc97a6b6c5 Fix ProfileCvController null warning 2026-04-11 17:13:25 +02:00
cesnimda 5f2f0a881a Record authorization replay findings 2026-04-11 17:07:10 +02:00
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
184 changed files with 18618 additions and 2820 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/**
+10
View File
@@ -13,6 +13,16 @@ AI_SERVICE_BASE_URL=http://ai-service:8001
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=qwen2.5:7b
# AI provider for the heavy /cv/* calls: ollama (default, local) | gemini | groq.
# /summarize always stays local (distilbart). To offload a weak production GPU,
# set AI_PROVIDER=gemini (or groq) and provide the matching key below.
# Keys are read from the environment only — never commit real keys.
AI_PROVIDER=ollama
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.0-flash
GROQ_API_KEY=
GROQ_MODEL=llama-3.3-70b-versatile
# Optional: only needed if you want the UI to call a non-default API base URL.
# In production the UI defaults to `/api`.
REACT_APP_API_BASE_URL=
+47 -30
View File
@@ -13,10 +13,22 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Setup .NET (resilient)
shell: bash
# actions/setup-dotnet on this single self-hosted runner intermittently
# leaves a partial extraction in the shared tool-cache ("tar: Cannot open:
# File exists") or corrupts the SDK download. Install into a clean private
# dir via dotnet-install.sh and retry once on failure, mirroring the
# npm ci / NuGet retries elsewhere in this workflow.
run: |
install() {
curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
rm -rf "$HOME/.dotnet"
bash /tmp/dotnet-install.sh --channel 9.0 --install-dir "$HOME/.dotnet"
}
install || ( echo "dotnet install failed ($?) — retrying once..." && install )
echo "$HOME/.dotnet" >> "$GITHUB_PATH"
"$HOME/.dotnet/dotnet" --info
- name: Setup Node
uses: actions/setup-node@v4
@@ -39,11 +51,18 @@ jobs:
run: |
node -v
npm -v
npm ci --no-audit --no-fund
# npm ci occasionally segfaults on the runner (SIGSEGV/139, a memory/native
# flake). Retry once with a clean node_modules before failing the job.
npm ci --no-audit --no-fund \
|| ( echo "npm ci failed ($?) — cleaning node_modules and retrying once..." \
&& rm -rf node_modules \
&& npm ci --no-audit --no-fund )
- name: Test frontend
working-directory: job-tracker-ui
run: npm test -- --watchAll=false --runInBand App.test.tsx confirm.test.tsx prompt.test.tsx dialog-flow.test.tsx confirm-flow.test.tsx attachments.test.tsx job-details-generated-drafts.test.tsx admin-system-page.test.tsx profile-page.test.tsx login-page.test.tsx
# Run the WHOLE suite. Never whitelist test files here again: the previous
# whitelist silently skipped new suites and let two regressions reach main.
run: npm test -- --watchAll=false --runInBand
- name: Build frontend
working-directory: job-tracker-ui
@@ -64,6 +83,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 +109,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
+11
View File
@@ -46,6 +46,14 @@ todo jobtracker.txt
tmp/
/tmp/
# Runtime data that must never be committed (DataProtection keys, exports, CV artifacts)
keys/
backups/
JobTrackerApi/exports/
JobTrackerApi/CvArtifacts/
JobTrackerApi/CvExports/
JobTrackerApi/CvBenchmarks/
# Local app data
*.db
*.db-*
@@ -75,3 +83,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
+43
View File
@@ -13,3 +13,46 @@
{"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S03"},"ts":"2026-03-28T22:05:32.786Z","actor":"agent","hash":"ae2f80720d601a48","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
{"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S04"},"ts":"2026-03-28T22:05:48.342Z","actor":"agent","hash":"38e10b5bfc9e49e6","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
{"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S05"},"ts":"2026-03-28T22:06:02.267Z","actor":"agent","hash":"a4cdfef1b0f97af3","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
{"cmd":"plan-milestone","params":{"milestoneId":"M006"},"ts":"2026-04-01T13:42:13.507Z","actor":"agent","hash":"4e6e2177aea2c247","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"}
{"cmd":"plan-milestone","params":{"milestoneId":"M007"},"ts":"2026-04-01T13:45:43.599Z","actor":"agent","hash":"f74c11f87b160d5e","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"}
{"cmd":"plan-milestone","params":{"milestoneId":"M010"},"ts":"2026-04-01T13:45:43.608Z","actor":"agent","hash":"0767a15a4163e364","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"}
{"cmd":"plan-milestone","params":{"milestoneId":"M009"},"ts":"2026-04-01T13:45:43.609Z","actor":"agent","hash":"868651dc3e9840ba","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"}
{"cmd":"plan-milestone","params":{"milestoneId":"M008"},"ts":"2026-04-01T13:45:43.611Z","actor":"agent","hash":"a17e013ae4c6fbc7","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"}
{"cmd":"plan-slice","params":{"milestoneId":"M006","sliceId":"S01"},"ts":"2026-04-01T13:46:55.228Z","actor":"agent","hash":"53e13651ee21608e","session_id":"4611175a-96ec-432d-832a-0269486cb6ff"}
{"v":2,"cmd":"plan-milestone","params":{"milestoneId":"M011"},"ts":"2026-04-10T16:33:49.574Z","actor":"agent","hash":"8da5bd1f6d8be219","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S01"},"ts":"2026-04-10T16:36:01.325Z","actor":"agent","hash":"1b39eb81745f79cb","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S01","taskId":"T01"},"ts":"2026-04-10T16:45:13.023Z","actor":"agent","hash":"df43e89bf0ef508a","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S01","taskId":"T02"},"ts":"2026-04-10T16:46:52.982Z","actor":"agent","hash":"fc183a287cf7e0ec","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S01","taskId":"T03"},"ts":"2026-04-10T16:47:07.060Z","actor":"agent","hash":"96dbf0b722260441","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S01"},"ts":"2026-04-10T16:47:38.406Z","actor":"agent","hash":"e8b7e8fcc07292af","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S01"},"ts":"2026-04-10T16:47:48.162Z","actor":"agent","hash":"e8d28553a74cd045","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S02"},"ts":"2026-04-10T16:48:16.316Z","actor":"agent","hash":"c6f7c425cd77c100","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S02","taskId":"T01"},"ts":"2026-04-10T16:49:40.607Z","actor":"agent","hash":"1e247d4737f232b4","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S02","taskId":"T02"},"ts":"2026-04-10T19:57:16.264Z","actor":"agent","hash":"02eb6bc1686244e9","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S02","taskId":"T03"},"ts":"2026-04-10T19:57:41.031Z","actor":"agent","hash":"85c32d040f9631aa","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S02"},"ts":"2026-04-10T19:58:17.389Z","actor":"agent","hash":"3115b597816bc8cb","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S02"},"ts":"2026-04-10T19:58:21.945Z","actor":"agent","hash":"51ed90ab022e6ae9","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S03"},"ts":"2026-04-10T22:04:32.223Z","actor":"agent","hash":"10a79a238ead7007","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S03","taskId":"T01"},"ts":"2026-04-10T22:05:25.953Z","actor":"agent","hash":"4d7f978e674fb278","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S03","taskId":"T02"},"ts":"2026-04-10T22:19:14.274Z","actor":"agent","hash":"94e0f7a9b24dd246","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S03","taskId":"T03"},"ts":"2026-04-10T22:19:33.234Z","actor":"agent","hash":"31c5bb74a3280df0","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S03"},"ts":"2026-04-10T22:20:05.975Z","actor":"agent","hash":"7a76f48b67c6a4fa","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S03"},"ts":"2026-04-10T22:20:18.782Z","actor":"agent","hash":"0bdf677f91c94f7b","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S04"},"ts":"2026-04-10T22:32:19.950Z","actor":"agent","hash":"ad5a195d23e3979e","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S04","taskId":"T01"},"ts":"2026-04-10T22:33:06.567Z","actor":"agent","hash":"dff04d446600fb9c","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S04","taskId":"T02"},"ts":"2026-04-10T22:44:02.977Z","actor":"agent","hash":"0a94bd5f4e0d3c90","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S04","taskId":"T03"},"ts":"2026-04-10T22:44:23.671Z","actor":"agent","hash":"6148706a46d32f7b","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S04"},"ts":"2026-04-10T22:44:54.430Z","actor":"agent","hash":"e68c20060f20dd34","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S04"},"ts":"2026-04-10T22:45:07.836Z","actor":"agent","hash":"f92571f10029d5e9","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S05"},"ts":"2026-04-10T22:55:56.643Z","actor":"agent","hash":"39e7d7ed3cc34612","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S05","taskId":"T01"},"ts":"2026-04-10T22:56:09.946Z","actor":"agent","hash":"c133fd6bf6b26629","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S05","taskId":"T02"},"ts":"2026-04-10T22:59:48.954Z","actor":"agent","hash":"f603df2d0e5cd772","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S05","taskId":"T03"},"ts":"2026-04-10T23:00:30.352Z","actor":"agent","hash":"96ecf88ce819d73b","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S05"},"ts":"2026-04-10T23:00:57.810Z","actor":"agent","hash":"31a2aca44265f192","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"reassess-roadmap","params":{"milestoneId":"M011","completedSliceId":"S05"},"ts":"2026-04-10T23:01:02.519Z","actor":"agent","hash":"fe0bd7ec6ab8df21","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"plan-slice","params":{"milestoneId":"M011","sliceId":"S06"},"ts":"2026-04-10T23:01:49.394Z","actor":"agent","hash":"f2b438884ca52230","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S06","taskId":"T01"},"ts":"2026-04-10T23:20:01.968Z","actor":"agent","hash":"406e0f3c172d1161","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S06","taskId":"T02"},"ts":"2026-04-10T23:24:03.823Z","actor":"agent","hash":"1a2544dcd9f4f925","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-task","params":{"milestoneId":"M011","sliceId":"S06","taskId":"T03"},"ts":"2026-04-10T23:24:23.101Z","actor":"agent","hash":"f583516649531d4c","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-slice","params":{"milestoneId":"M011","sliceId":"S06"},"ts":"2026-04-10T23:24:52.479Z","actor":"agent","hash":"b2c2dc564fb09dfe","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
{"v":2,"cmd":"complete-milestone","params":{"milestoneId":"M011"},"ts":"2026-04-10T23:25:36.547Z","actor":"agent","hash":"10b42cbd47fe0d4a","session_id":"f90d26d1-ea3a-48a7-b5ff-50c99ba96644"}
+2560 -2
View File
File diff suppressed because one or more lines are too long
-23
View File
@@ -1,23 +0,0 @@
[ApiController]
[Route("api/[controller]")]
public class AttachmentsController : ControllerBase
{
private readonly IWebHostEnvironment _env;
public AttachmentsController(IWebHostEnvironment env) => _env = env;
[HttpPost]
public async Task<IActionResult> Upload([FromForm] IFormFileCollection files, [FromForm] int jobId)
{
var folder = Path.Combine(_env.ContentRootPath, "Attachments", jobId.ToString());
Directory.CreateDirectory(folder);
foreach (var file in files)
{
var path = Path.Combine(folder, file.FileName);
using var stream = new FileStream(path, FileMode.Create);
await file.CopyToAsync(stream);
}
return Ok();
}
}
-27
View File
@@ -1,27 +0,0 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class CompaniesController : ControllerBase
{
private readonly JobTrackerContext _context;
public CompaniesController(JobTrackerContext context) => _context = context;
[HttpGet]
public async Task<IEnumerable<Company>> Get() =>
await _context.Companies.Include(c => c.Jobs).ToListAsync();
[HttpPost]
public async Task<ActionResult<Company>> Post(Company company)
{
_context.Companies.Add(company);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(Get), new { id = company.Id }, company);
}
}
}
-34
View File
@@ -1,34 +0,0 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class CorrespondenceController : ControllerBase
{
private readonly JobTrackerContext _context;
public CorrespondenceController(JobTrackerContext context) => _context = context;
// GET all messages for a job
[HttpGet("{jobId}")]
public async Task<IEnumerable<Correspondence>> GetForJob(int jobId)
{
return await _context.Correspondences
.Where(c => c.JobApplicationId == jobId)
.OrderBy(c => c.Date)
.ToListAsync();
}
// POST new message
[HttpPost]
public async Task<ActionResult<Correspondence>> Post(Correspondence message)
{
_context.Correspondences.Add(message);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetForJob), new { jobId = message.JobApplicationId }, message);
}
}
}
-45
View File
@@ -1,45 +0,0 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class JobApplicationsController : ControllerBase
{
private readonly JobTrackerContext _context;
public JobApplicationsController(JobTrackerContext context) => _context = context;
[HttpGet]
public async Task<IEnumerable<JobApplication>> Get() =>
await _context.JobApplications.Include(j => j.Company).ToListAsync();
[HttpPost]
public async Task<ActionResult<JobApplication>> Post(JobApplication job)
{
_context.JobApplications.Add(job);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(Get), new { id = job.Id }, job);
}
[HttpPut("{id}")]
public async Task<IActionResult> Put(int id, JobApplication updatedJob)
{
var job = await _context.JobApplications.FindAsync(id);
if (job == null) return NotFound();
job.JobTitle = updatedJob.JobTitle;
job.Status = updatedJob.Status;
job.ResponseReceived = updatedJob.ResponseReceived;
job.ResponseDate = updatedJob.ResponseDate;
job.Notes = updatedJob.Notes;
job.CoverLetterText = updatedJob.CoverLetterText;
job.JobUrl = updatedJob.JobUrl;
await _context.SaveChangesAsync();
return NoContent();
}
}
}
+31 -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 });
@@ -54,17 +55,37 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<JobApplication>()
.HasIndex(j => j.OwnerUserId);
// Owner-prefixed composite indexes for the tenant-scoped hot paths. Every
// JobApplication query is scoped by the OwnerUserId global filter first, then
// filtered by IsDeleted (list/board/stats/analytics) or FollowUpAt (reminders).
// Status is intentionally excluded from the index because Pomelo maps the
// unbounded string column to longtext, which MariaDB cannot index without a
// prefix length. The actual index DDL is applied idempotently in
// StartupInitializationExtensions (this repo provisions schema via that
// reconciler, not via the EF ModelSnapshot, which is stale).
modelBuilder.Entity<JobApplication>()
.HasIndex(j => new { j.OwnerUserId, j.IsDeleted });
modelBuilder.Entity<JobApplication>()
.HasIndex(j => new { j.OwnerUserId, j.FollowUpAt });
modelBuilder.Entity<Company>()
.HasIndex(c => c.OwnerUserId);
modelBuilder.Entity<Correspondence>()
.HasQueryFilter(c => CurrentUserId != null && c.JobApplication.OwnerUserId == CurrentUserId)
.HasOne(c => c.JobApplication)
.WithMany(j => j.Messages)
.HasForeignKey(c => c.JobApplicationId)
.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 +100,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 +110,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 +128,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 })
@@ -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()
{
@@ -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()
{
@@ -1,8 +1,10 @@
using System.Reflection;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
@@ -12,6 +14,22 @@ namespace JobTrackerApi.Tests;
public sealed class BackupControllerTests
{
[Fact]
public void Backup_controller_requires_local_authorization()
{
var attribute = typeof(BackupController).GetCustomAttribute<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()
{
@@ -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() { }
}
}
+266 -22
View File
@@ -1,4 +1,6 @@
using System.IO.Enumeration;
using System.Reflection;
using System.Security.Cryptography;
using System.Text.Json;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
@@ -23,17 +25,27 @@ public sealed class CvCorpusHarnessTests
{
if (!Directory.Exists(CorpusRoot)) return;
var ignoredPatterns = ResolveIgnoredPatterns();
var files = Directory.EnumerateFiles(CorpusRoot, "*.*", SearchOption.TopDirectoryOnly)
.Where(path => path.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
.Where(path => !IsIgnoredFile(path, ignoredPatterns))
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.Take(8)
.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);
@@ -42,20 +54,23 @@ public sealed class CvCorpusHarnessTests
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();
var controller = new ProfileCvController(userManager.Object, aiService.Object, db, paths, NoOpCvAiClassifier.Instance)
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 report = new List<object>();
var entries = new List<CvBenchmarkEntry>();
foreach (var path in files)
{
await using var stream = File.OpenRead(path);
@@ -71,32 +86,244 @@ public sealed class CvCorpusHarnessTests
var text = await extractTask;
Assert.False(string.IsNullOrWhiteSpace(text));
var buildTask = (Task<StructuredCvProfile>)buildMethod!.Invoke(controller, new object[] { text, CancellationToken.None })!;
var structured = await buildTask;
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);
report.Add(new
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)
{
file = fileName,
characters = text.Length,
contactLocation = structured.Contact.Location,
firstJob = structured.Jobs.FirstOrDefault()?.Title,
firstJobLocation = structured.Jobs.FirstOrDefault()?.Location,
firstEducation = structured.Education.FirstOrDefault()?.Qualification,
firstEducationLocation = structured.Education.FirstOrDefault()?.Location,
suspiciousLocations = structured.Jobs.Select(job => job.Location)
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()
});
.ToList(),
CoverageScore: ComputeCoverageScore(structured),
ConfidenceScore: ComputeConfidenceScore(structured),
ConsistencyScore: ComputeConsistencyScore(structured),
DiffSummary: diffSummary
));
}
var reportPath = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-corpus-{DateTime.UtcNow:yyyyMMddHHmmss}.json");
await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
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
);
Assert.True(report.Count > 0);
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)
@@ -119,7 +346,7 @@ public sealed class CvCorpusHarnessTests
};
}
private static AppPaths CreatePaths()
private static AppPaths CreatePaths(string outputRoot)
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-corpus-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
@@ -128,7 +355,8 @@ public sealed class CvCorpusHarnessTests
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Data:Root"] = tempRoot,
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts")
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts"),
["Data:CvBenchmarksRoot"] = outputRoot,
})
.Build();
@@ -136,4 +364,20 @@ public sealed class CvCorpusHarnessTests
env.SetupGet(x => x.ContentRootPath).Returns(tempRoot);
return new AppPaths(config, env.Object);
}
private static ICvAiNormalizer CreateCvAiNormalizerFromEnvironment()
{
var baseUrl = Environment.GetEnvironmentVariable("CV_AI_BASE_URL");
if (string.IsNullOrWhiteSpace(baseUrl)) return NoOpCvAiNormalizer.Instance;
var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
services.AddHttpClient("ai-service", client =>
{
client.BaseAddress = new Uri(baseUrl.Trim());
client.Timeout = TimeSpan.FromSeconds(180);
});
var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<System.Net.Http.IHttpClientFactory>();
return new CvAiNormalizer(factory);
}
}
@@ -0,0 +1,97 @@
using JobTrackerApi.Services;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class DatabaseBackupRunnerTests : IDisposable
{
private readonly string _root;
public DatabaseBackupRunnerTests()
{
_root = Path.Combine(Path.GetTempPath(), $"jt-backup-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(_root);
}
public void Dispose()
{
SqliteConnection.ClearAllPools();
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
}
private string CreateSourceDb(out string connectionString)
{
var dbPath = Path.Combine(_root, "source.db");
connectionString = $"Data Source={dbPath}";
using var connection = new SqliteConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "CREATE TABLE Sample (Id INTEGER PRIMARY KEY, Name TEXT); INSERT INTO Sample (Name) VALUES ('alpha'), ('beta');";
command.ExecuteNonQuery();
return dbPath;
}
private SqliteDatabaseBackupRunner CreateRunner(string connectionString, int retainCount = 14)
=> new(connectionString, Path.Combine(_root, "backups"), retainCount, NullLogger<SqliteDatabaseBackupRunner>.Instance);
[Fact]
public async Task RunOnce_creates_a_restorable_backup_file()
{
CreateSourceDb(out var connectionString);
var runner = CreateRunner(connectionString);
var backupPath = await runner.RunOnceAsync(CancellationToken.None);
Assert.NotNull(backupPath);
Assert.True(File.Exists(backupPath));
await using var verify = new SqliteConnection($"Data Source={backupPath}");
await verify.OpenAsync();
await using var count = verify.CreateCommand();
count.CommandText = "SELECT COUNT(*) FROM Sample";
Assert.Equal(2L, (long)(await count.ExecuteScalarAsync())!);
}
[Fact]
public async Task RunOnce_prunes_backups_beyond_retention()
{
CreateSourceDb(out var connectionString);
var runner = CreateRunner(connectionString, retainCount: 2);
var backupsRoot = runner.BackupsRoot;
Directory.CreateDirectory(backupsRoot);
for (var i = 0; i < 3; i++)
{
var stale = Path.Combine(backupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}stale{i}.db");
File.WriteAllText(stale, "stale");
File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddDays(-10 - i));
}
await runner.RunOnceAsync(CancellationToken.None);
var remaining = Directory.GetFiles(backupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}*.db");
Assert.Equal(2, remaining.Length);
Assert.Contains(remaining, f => Path.GetFileName(f).Contains("stale0"));
}
[Fact]
public void Latest_backup_timestamp_reflects_newest_file()
{
CreateSourceDb(out var connectionString);
var runner = CreateRunner(connectionString);
Assert.Null(runner.GetLatestBackupUtc());
Directory.CreateDirectory(runner.BackupsRoot);
var file = Path.Combine(runner.BackupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}x.db");
File.WriteAllText(file, "x");
var stamp = DateTime.UtcNow.AddHours(-3);
File.SetLastWriteTimeUtc(file, stamp);
var latest = runner.GetLatestBackupUtc();
Assert.NotNull(latest);
Assert.True(Math.Abs((latest!.Value - stamp).TotalSeconds) < 2);
}
}
@@ -0,0 +1,61 @@
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class EmailStatusClassifierTests
{
[Fact]
public void Detects_rejection()
{
var s = EmailStatusClassifier.Classify("Your application", "Thank you for your time. Unfortunately, we have decided not to proceed with your application.");
Assert.NotNull(s);
Assert.Equal("Rejected", s!.SuggestedStatus);
}
[Fact]
public void Detects_offer()
{
var s = EmailStatusClassifier.Classify("Great news", "We are pleased to offer you the position of Backend Engineer.");
Assert.NotNull(s);
Assert.Equal("Offer", s!.SuggestedStatus);
}
[Fact]
public void Detects_interview_invite()
{
var s = EmailStatusClassifier.Classify("Next steps", "We would like to invite you to interview next week. What is your availability for a call?");
Assert.NotNull(s);
Assert.Equal("Interview", s!.SuggestedStatus);
}
[Fact]
public void Rejection_wins_over_interview_mention()
{
// A rejection email that references the interview the candidate had must classify as Rejected.
var s = EmailStatusClassifier.Classify(
"Update on your application",
"Thank you for taking the time to interview with us. Unfortunately, we will not be moving forward.");
Assert.NotNull(s);
Assert.Equal("Rejected", s!.SuggestedStatus);
}
[Fact]
public void Weak_interview_cue_is_low_confidence()
{
var s = EmailStatusClassifier.Classify("Coding challenge", "Please complete this take-home assessment.");
Assert.NotNull(s);
Assert.Equal("Interview", s!.SuggestedStatus);
Assert.Equal("low", s.Confidence);
}
[Fact]
public void Returns_null_for_neutral_email()
{
Assert.Null(EmailStatusClassifier.Classify("Re: question", "Thanks for the info, that answers my question about the parking."));
}
[Fact]
public void Handles_empty_input()
=> Assert.Null(EmailStatusClassifier.Classify(null, null));
}
+413 -5
View File
@@ -15,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()
{
@@ -225,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");
@@ -239,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);
@@ -282,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",
@@ -293,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" });
@@ -365,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);
@@ -435,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()
{
@@ -500,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
{
@@ -0,0 +1,99 @@
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);
}
[Fact]
public async Task GetMatchScore_returns_not_found_for_other_users_job()
{
var dbName = Guid.NewGuid().ToString();
await using var ownerDb = CreateDb(dbName, "owner-1");
var company = new Company { Name = "Acme", OwnerUserId = "owner-1" };
ownerDb.Companies.Add(company);
await ownerDb.SaveChangesAsync();
ownerDb.JobApplications.Add(new JobApplication { JobTitle = "Secret", CompanyId = company.Id, OwnerUserId = "owner-1", Description = "C# .NET" });
await ownerDb.SaveChangesAsync();
var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync();
await using var attackerDb = CreateDb(dbName, "other-user");
var result = await CreateController(attackerDb).GetMatchScore(jobId, CancellationToken.None);
Assert.IsType<NotFoundResult>(result.Result);
}
[Fact]
public async Task GetStatusSuggestion_returns_not_found_for_other_users_job()
{
var dbName = Guid.NewGuid().ToString();
await using var ownerDb = CreateDb(dbName, "owner-1");
var company = new Company { Name = "Acme", OwnerUserId = "owner-1" };
ownerDb.Companies.Add(company);
await ownerDb.SaveChangesAsync();
ownerDb.JobApplications.Add(new JobApplication { JobTitle = "Secret", CompanyId = company.Id, OwnerUserId = "owner-1" });
await ownerDb.SaveChangesAsync();
var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync();
await using var attackerDb = CreateDb(dbName, "other-user");
var result = await CreateController(attackerDb).GetStatusSuggestion(jobId, CancellationToken.None);
Assert.IsType<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);
}
@@ -56,6 +56,231 @@ public sealed class JobApplicationsEndpointBehaviorTests
Assert.Contains("Profile page", badRequest.Value?.ToString());
}
[Fact]
public async Task Status_suggestion_from_latest_inbound_rejection()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Applied" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
db.Correspondences.Add(new Correspondence
{
JobApplicationId = job.Id,
From = "Company",
Direction = "inbound",
Subject = "Update",
Content = "Unfortunately, we have decided not to proceed.",
Date = DateTime.Now,
});
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
Assert.True(dto.HasSuggestion);
Assert.Equal("Rejected", dto.SuggestedStatus);
}
[Fact]
public async Task Status_suggestion_suppressed_when_already_in_stage()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Rejected" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
db.Correspondences.Add(new Correspondence
{
JobApplicationId = job.Id,
From = "Company",
Direction = "inbound",
Content = "Unfortunately, we will not be moving forward.",
Date = DateTime.Now,
});
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
Assert.False(dto.HasSuggestion);
}
[Fact]
public async Task Match_score_scores_job_against_profile_cv()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
db.Users.Add(new ApplicationUser
{
Id = "user-1",
UserName = "u",
Email = "u@example.com",
ProfileCvText = "Backend engineer skilled in C#, .NET, SQL and Docker. Built REST APIs.",
});
await db.SaveChangesAsync();
var job = new JobApplication
{
JobTitle = "Senior C# Backend Developer",
CompanyId = company.Id,
OwnerUserId = "user-1",
Description = "We need strong C#, .NET, SQL, Docker and REST API experience.",
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var result = await controller.GetMatchScore(job.Id, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<JobApplicationsController.MatchScoreDto>(ok.Value);
Assert.True(dto.HasEnoughSignal);
Assert.True(dto.Score >= 75, $"expected strong score, got {dto.Score}");
Assert.Contains("C#", dto.MatchedKeywords);
}
[Fact]
public async Task Match_score_requires_profile_cv()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "u", Email = "u@example.com" });
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Description = "C# .NET" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var result = await controller.GetMatchScore(job.Id, CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(result.Result);
}
[Fact]
public async Task Create_normalizes_structured_salary()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var request = new JobApplicationsController.CreateJobApplicationRequest(
JobTitle: "Backend Dev",
CompanyId: company.Id,
Status: null,
Location: null,
Salary: "60-70k",
SalaryMin: 70000m, // min > max on purpose: normalization swaps them
SalaryMax: 60000m,
SalaryCurrency: " nok ",
SalaryPeriod: "YEAR",
NextAction: null,
FollowUpAt: null,
Notes: null,
Description: null,
TranslatedDescription: null,
DescriptionLanguage: null,
Tags: null,
Deadline: null,
CoverLetterText: null,
JobUrl: null,
DateApplied: null,
FeedbackRequestedAt: null,
HasResume: null,
HasCoverLetter: null,
HasPortfolio: null,
HasOtherAttachment: null);
var result = await controller.Create(request, CancellationToken.None);
Assert.NotNull(result);
var saved = await db.JobApplications.FirstAsync();
Assert.Equal(60000m, saved.SalaryMin);
Assert.Equal(70000m, saved.SalaryMax);
Assert.Equal("NOK", saved.SalaryCurrency);
Assert.Equal("year", saved.SalaryPeriod);
Assert.Equal("60-70k", saved.Salary);
}
[Fact]
public async Task Update_drops_invalid_salary_period_and_negative_values()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication
{
JobTitle = "Backend Dev",
CompanyId = company.Id,
OwnerUserId = "user-1",
SalaryMin = 50000m,
SalaryMax = 60000m,
SalaryCurrency = "NOK",
SalaryPeriod = "year",
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var controller = CreateController(db, "user-1");
var request = new JobApplicationsController.UpdateJobApplicationRequest(
JobTitle: "Backend Dev",
CompanyId: company.Id,
Status: "Applied",
ResponseReceived: false,
ResponseDate: null,
Location: null,
Salary: null,
SalaryMin: -5m,
SalaryMax: null,
SalaryCurrency: "",
SalaryPeriod: "fortnight",
NextAction: null,
FollowUpAt: null,
HasResume: null,
HasCoverLetter: null,
HasPortfolio: null,
HasOtherAttachment: null,
Notes: null,
Description: null,
TranslatedDescription: null,
DescriptionLanguage: null,
Tags: null,
Deadline: null,
CoverLetterText: null,
JobUrl: null,
DateApplied: null,
FeedbackRequestedAt: null,
StatusChangedAt: null);
var result = await controller.Update(job.Id, request, CancellationToken.None);
Assert.IsType<NoContentResult>(result);
var saved = await db.JobApplications.FirstAsync();
Assert.Null(saved.SalaryMin);
Assert.Null(saved.SalaryMax);
Assert.Null(saved.SalaryCurrency);
Assert.Null(saved.SalaryPeriod);
}
private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
{
var summarizer = new Mock<ISummarizerService>();
@@ -0,0 +1,105 @@
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class JobCvMatchServiceTests
{
private readonly JobCvMatchService _service = new();
private static Dictionary<string, string> Sections(params (string Name, string Text)[] items)
=> items.ToDictionary(i => i.Name, i => i.Text, StringComparer.OrdinalIgnoreCase);
[Fact]
public void Strong_overlap_scores_high_and_lists_matched_keywords()
{
var result = _service.Evaluate(
jobTitle: "Senior C# Backend Developer",
jobText: "We need a backend engineer with strong C#, .NET, SQL and Docker experience building REST APIs.",
cvSections: Sections(
("Skills", "C# .NET SQL Docker Kubernetes"),
("Experience", "Built REST APIs in C# and .NET with SQL Server and Docker.")));
Assert.True(result.Score >= 75, $"expected strong score, got {result.Score}");
Assert.Equal("Strong", result.Band);
Assert.Contains("C#", result.MatchedKeywords);
Assert.Contains(".NET", result.MatchedKeywords);
Assert.True(result.HasEnoughSignal);
}
[Fact]
public void No_overlap_scores_low_and_surfaces_missing_keywords()
{
var result = _service.Evaluate(
jobTitle: "Kubernetes Platform Engineer",
jobText: "Deep Kubernetes, AWS, and Docker platform experience required. Terraform and CI/CD pipelines.",
cvSections: Sections(
("Skills", "Graphic design, Adobe Photoshop, Illustrator, copywriting"),
("Experience", "Ran marketing campaigns and brand design work.")));
Assert.True(result.Score < 50, $"expected low score, got {result.Score}");
Assert.Equal("Low", result.Band);
Assert.Contains("Kubernetes", result.MissingKeywords);
Assert.Contains("AWS", result.MissingKeywords);
}
[Fact]
public void Is_deterministic_for_identical_inputs()
{
var a = _service.Evaluate("Data Engineer", "Python, SQL, Spark, ETL pipelines and AWS.", Sections(("Skills", "Python SQL AWS")));
var b = _service.Evaluate("Data Engineer", "Python, SQL, Spark, ETL pipelines and AWS.", Sections(("Skills", "Python SQL AWS")));
Assert.Equal(a.Score, b.Score);
Assert.Equal(a.MatchedKeywords, b.MatchedKeywords);
Assert.Equal(a.MissingKeywords, b.MissingKeywords);
}
[Fact]
public void Word_boundary_prevents_false_substring_matches()
{
// "go" (the language) must not match inside "goals"/"ago".
var result = _service.Evaluate(
jobTitle: "Go Developer",
jobText: "Go programming language, goroutines, concurrency.",
cvSections: Sections(("Experience", "Achieved company goals two years ago in a great environment.")));
Assert.DoesNotContain("go", result.MatchedKeywords, StringComparer.OrdinalIgnoreCase);
}
[Fact]
public void Section_coverage_reports_where_matches_are_concentrated()
{
var result = _service.Evaluate(
jobTitle: "React Frontend Engineer",
jobText: "Build UIs with React, TypeScript and JavaScript. Strong testing culture.",
cvSections: Sections(
("Skills", "React TypeScript JavaScript"),
("Experience", "Wrote documentation and managed budgets.")));
var skills = Assert.Single(result.SectionCoverage, s => s.Section == "Skills");
var experience = Assert.Single(result.SectionCoverage, s => s.Section == "Experience");
Assert.True(skills.Matched > experience.Matched);
}
[Fact]
public void Empty_cv_reports_no_signal()
{
var result = _service.Evaluate("Anything", "Some role text with several words here.", Sections());
Assert.False(result.HasEnoughSignal);
Assert.Equal("Unknown", result.Band);
Assert.Equal(0, result.MatchedCount);
}
[Fact]
public void Title_keywords_are_weighted_and_missing_ones_rank_first()
{
// The title term "kubernetes" is absent from the CV; it should lead the missing list
// because title terms carry the title bonus weight.
var result = _service.Evaluate(
jobTitle: "Kubernetes Specialist",
jobText: "Kubernetes orchestration. Some familiarity with logging and monitoring dashboards.",
cvSections: Sections(("Skills", "logging monitoring dashboards")));
Assert.Equal("Kubernetes", result.MissingKeywords.First());
}
}
@@ -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));
}
}
+54
View File
@@ -0,0 +1,54 @@
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class JobPipelineTests
{
[Theory]
[InlineData("applied", "Applied")]
[InlineData("APPLIED", "Applied")]
[InlineData(" Offer ", "Offer")]
[InlineData("Interviewing", "Interview")]
[InlineData("interviews", "Interview")]
[InlineData("declined", "Rejected")]
[InlineData("no response", "Ghosted")]
public void Normalize_canonicalizes_casing_and_synonyms(string input, string expected)
=> Assert.Equal(expected, JobPipeline.Normalize(input));
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Normalize_empty_becomes_default(string? input)
=> Assert.Equal("Applied", JobPipeline.Normalize(input));
[Fact]
public void Normalize_preserves_unknown_custom_status()
=> Assert.Equal("Take-home assignment", JobPipeline.Normalize(" Take-home assignment "));
[Fact]
public void Stages_are_ordered_and_unique()
{
var orders = JobPipeline.Stages.Select(s => s.Order).ToList();
Assert.Equal(orders.OrderBy(x => x), orders);
Assert.Equal(orders.Count, orders.Distinct().Count());
}
[Fact]
public void OrderOf_sorts_canonical_before_custom()
{
Assert.True(JobPipeline.OrderOf("Applied") < JobPipeline.OrderOf("Offer"));
Assert.True(JobPipeline.OrderOf("Offer") < JobPipeline.OrderOf("Custom stage"));
Assert.Equal(JobPipeline.OrderOf("Interview"), JobPipeline.OrderOf("Interviewing"));
}
[Fact]
public void IsCanonical_only_true_for_known_stages()
{
Assert.True(JobPipeline.IsCanonical("Offer"));
Assert.True(JobPipeline.IsCanonical("offer"));
Assert.False(JobPipeline.IsCanonical("Interviewing")); // synonym, not canonical
Assert.False(JobPipeline.IsCanonical("Whatever"));
}
}
@@ -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());
}
}
+427 -4
View File
@@ -1,6 +1,7 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
@@ -134,6 +135,7 @@ public sealed class ProfileCvControllerTests
var user = new ApplicationUser { Id = "user-1", CurrentCvProfileVersion = 1 };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<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
@@ -176,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);
@@ -276,6 +283,55 @@ public sealed class ProfileCvControllerTests
Assert.Contains(structured.Sections, section => section.Name == "Education");
}
[Fact]
public async Task Upload_uses_ai_normalizer_fallback_when_flattened_text_stays_low_structure()
{
var rawExtraction = "connor.babbington@cesnimda.co.uk cesnimda.co.uk +47 41 33 44 70 E X P E R I E N C E S Y S T E M D E V E L O P E R 2015 - 2023 Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript. + Warwickshire County Council, UK";
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<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()
{
@@ -500,6 +556,129 @@ public sealed class ProfileCvControllerTests
Assert.Equal("Warwickshire College, UK", structured.Education[0].Location);
}
[Fact]
public async Task Rewrite_section_returns_ai_service_unavailable_detail_when_ai_health_is_unhealthy()
{
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.IsAny<string>(), It.IsAny<string>(), 1800, 400))
.ReturnsAsync(string.Empty);
aiService
.Setup(x => x.GetMetricsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiServiceMetrics(
Healthy: false,
Model: "distilbart",
Device: "cpu",
GpuAvailable: false,
GpuName: null,
OcrAvailable: true,
OcrLanguages: "eng",
OllamaConfigured: true,
OllamaReachable: true,
OllamaModel: "qwen2.5:7b",
OllamaModelAvailable: true,
OllamaVersion: "0.6.0",
OllamaInstalledModels: new List<string> { "qwen2.5:7b" },
OllamaLoadedModels: new List<string>(),
OllamaLoadedCount: 0,
HealthLatencyMs: 21,
ProbeLatencyMs: null,
LastProbeAt: null,
LastProbeSuccessAt: null,
LastProbeFailureAt: null,
ProbeFailures: 1,
Requests: 1,
CacheHits: 0,
CacheMisses: 1,
Failures: 1,
AverageLatencyMs: 21,
OcrRequests: 0,
OcrFailures: 0,
AverageOcrLatencyMs: null,
LastOcrSuccessAt: null,
LastOcrFailureAt: null,
LastSuccessAt: null,
LastFailureAt: DateTimeOffset.UtcNow,
LastError: "Model loading is disabled by AI_SERVICE_SKIP_MODEL_LOAD."));
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest());
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode);
var payload = Assert.IsType<ProfileCvController.CvRewriteFailureDto>(objectResult.Value);
Assert.Equal("ai-service-unavailable", payload.Code);
Assert.Contains("could not rewrite", payload.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("unavailable", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("AI_SERVICE_SKIP_MODEL_LOAD", payload.LastAiError ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Rewrite_section_returns_rewrite_empty_detail_when_ai_health_is_healthy()
{
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.IsAny<string>(), It.IsAny<string>(), 1800, 400))
.ReturnsAsync(string.Empty);
aiService
.Setup(x => x.GetMetricsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiServiceMetrics(
Healthy: true,
Model: "distilbart",
Device: "cpu",
GpuAvailable: false,
GpuName: null,
OcrAvailable: true,
OcrLanguages: "eng",
OllamaConfigured: true,
OllamaReachable: true,
OllamaModel: "qwen2.5:7b",
OllamaModelAvailable: true,
OllamaVersion: "0.6.0",
OllamaInstalledModels: new List<string> { "qwen2.5:7b" },
OllamaLoadedModels: new List<string>(),
OllamaLoadedCount: 0,
HealthLatencyMs: 21,
ProbeLatencyMs: null,
LastProbeAt: null,
LastProbeSuccessAt: null,
LastProbeFailureAt: null,
ProbeFailures: 0,
Requests: 1,
CacheHits: 0,
CacheMisses: 1,
Failures: 0,
AverageLatencyMs: 21,
OcrRequests: 0,
OcrFailures: 0,
AverageOcrLatencyMs: null,
LastOcrSuccessAt: null,
LastOcrFailureAt: null,
LastSuccessAt: DateTimeOffset.UtcNow,
LastFailureAt: null,
LastError: null));
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest());
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status502BadGateway, objectResult.StatusCode);
var payload = Assert.IsType<ProfileCvController.CvRewriteFailureDto>(objectResult.Value);
Assert.Equal("rewrite-empty", payload.Code);
Assert.Contains("empty", payload.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("no usable text", payload.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Rewrite_section_can_target_saved_job_context_and_whole_cv()
{
@@ -518,7 +697,12 @@ public sealed class ProfileCvControllerTests
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest(null, "harvard", null, 42, "harvard"));
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);
@@ -785,9 +969,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, ICvAiClassifier? cvAiClassifier = null)
[Fact]
public void Normalized_markdown_parse_preserves_real_estate_job_and_language_levels()
{
return new ProfileCvController(userManager, aiService, db, paths, cvAiClassifier ?? NoOpCvAiClassifier.Instance)
var normalized = "# Contact\nAvery Cooper\n(415) 223-4344\nhttps://www.linkedin.com/in/avery-cooper/\nhttps://www.realtor.com/realestateagents/avery-copper/\nSan Francisco\n\n# Professional Summary\nDynamic real estate professional with 12 years of experience in residential and commercial property.\n\n# Work Experience\nReal Estate Agent\nEleanor Lane Agency, White Plains\nJuly 2017 - Present\n- Managed all aspects of the sales process from preparation to close, achieving a 25% increase in closed deals compared to previous periods.\n- Successfully negotiated favorable terms for clients in over 50 real estate transactions, consistently securing above-market value.\n\nReal Estate Assistant\nHathaway Properties, New Rochelle\nOctober 2012 - June 2017\n- Managed administrative tasks in a fast-paced real estate office, ensuring smooth daily operations.\n- Supported Realtors and Brokers by coordinating marketing materials, client communications, and office transactions.\n\n# Skills\n- Contract Management\n- Retail Market Analysis\n- Property Valuation\n- Client Relationship Management\n- Digital Marketing\n- Attention to Detail\n\n# Languages\n- English (Native)\n- Spanish - C2";
var actual = ParseNormalizedMarkdown(normalized);
Assert.Equal("Avery Cooper", actual.Contact.FullName);
Assert.Equal("San Francisco", actual.Contact.Location);
Assert.NotEmpty(actual.Jobs);
Assert.Equal("Real Estate Agent", actual.Jobs[0].Title);
Assert.Equal("Eleanor Lane Agency, White Plains", actual.Jobs[0].Company);
Assert.True(actual.Jobs[0].Bullets.Count >= 2);
Assert.Contains("Contract Management", actual.Skills);
Assert.Contains(actual.Languages, item => string.Equals(item.Name, "Spanish", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "C2", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Normalized_markdown_parse_preserves_web_developer_bullets_and_skills()
{
var normalized = "# Contact\nChristoper Morgan\nchristoper.m@gmail.com\n+44 (0)20 7666 8555\n\n# Professional Summary\nSenior Web Developer specializing in front end development. Experienced with all stages of the development cycle for dynamic web projects.\n\n# Work Experience\nWeb Developer\nLuna Web Design, New York\n09/2015 - 05/2019\n- Cooperate with designers to create clean interfaces and simple, intuitive interactions and experiences.\n- Develop project concepts and maintain optimal workflow.\n- Work with senior developer to manage large, complex design projects for corporate clients.\n- Complete detailed programming and development tasks for front end public and internal websites as well as challenging back-end server code.\n- Carry out quality assurance tests to discover errors and optimize usability.\n\n# Skills\n- JavaScript\n- HTML5\n- PHP OOP\n- CSS\n- SQL\n- MySQL\n\n# Languages\n- Spanish - C2\n- Chinese - A1\n- German - A2";
var actual = ParseNormalizedMarkdown(normalized);
Assert.Equal("Christoper Morgan", actual.Contact.FullName);
Assert.NotEmpty(actual.Jobs);
Assert.Equal("Web Developer", actual.Jobs[0].Title);
Assert.Equal("Luna Web Design, New York", actual.Jobs[0].Company);
Assert.True(actual.Jobs[0].Bullets.Count >= 5);
Assert.Contains("JavaScript", actual.Skills);
Assert.Contains("MySQL", actual.Skills);
Assert.Contains(actual.Languages, item => string.Equals(item.Name, "Chinese", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "A1", StringComparison.OrdinalIgnoreCase));
Assert.Contains(actual.Languages, item => string.Equals(item.Name, "German", StringComparison.OrdinalIgnoreCase) && string.Equals(item.Level, "A2", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Parse_uses_forced_ai_normalizer_output_when_enabled()
{
var previous = Environment.GetEnvironmentVariable("CV_FORCE_AI_NORMALIZER");
Environment.SetEnvironmentVariable("CV_FORCE_AI_NORMALIZER", "true");
try
{
var source = "Avery CooperReal Estate Agent\nSan Francisco(415) 223-4344\nDynamic real estate professional with 12 years of experience in residential and commercial property.";
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<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() }
};
@@ -0,0 +1,62 @@
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class StageAnalyticsTests
{
private static readonly DateTime Now = new(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc);
[Fact]
public void Computes_median_days_per_active_stage()
{
var jobs = new[]
{
new StageOccupancy("Applied", Now.AddDays(-10)),
new StageOccupancy("Applied", Now.AddDays(-20)),
new StageOccupancy("Applied", Now.AddDays(-30)),
new StageOccupancy("Interview", Now.AddDays(-4)),
};
var result = StageAnalytics.TimeInStage(jobs, Now);
var applied = Assert.Single(result, p => p.Stage == "Applied");
Assert.Equal(20, applied.MedianDays);
Assert.Equal(3, applied.Count);
var interview = Assert.Single(result, p => p.Stage == "Interview");
Assert.Equal(4, interview.MedianDays);
}
[Fact]
public void Excludes_closed_and_success_stages()
{
var jobs = new[]
{
new StageOccupancy("Offer", Now.AddDays(-5)),
new StageOccupancy("Rejected", Now.AddDays(-5)),
new StageOccupancy("Ghosted", Now.AddDays(-5)),
};
Assert.Empty(StageAnalytics.TimeInStage(jobs, Now));
}
[Fact]
public void Normalizes_legacy_status_and_orders_by_pipeline()
{
var jobs = new[]
{
new StageOccupancy("Interviewing", Now.AddDays(-3)),
new StageOccupancy("Applied", Now.AddDays(-1)),
new StageOccupancy("Waiting", Now.AddDays(-2)),
};
var result = StageAnalytics.TimeInStage(jobs, Now);
Assert.Equal(new[] { "Applied", "Waiting", "Interview" }, result.Select(p => p.Stage).ToArray());
}
[Fact]
public void Empty_input_returns_empty()
=> Assert.Empty(StageAnalytics.TimeInStage(Array.Empty<StageOccupancy>(), Now));
}
@@ -0,0 +1,78 @@
using System.Net;
using System.Net.Http;
using System.Text;
using Microsoft.Extensions.Caching.Memory;
using Moq;
using Xunit;
using JobTrackerApi.Services;
namespace JobTrackerApi.Tests;
public sealed class SummarizerServiceTests
{
[Fact]
public async Task Summarize_section_uses_cv_rewrite_endpoint()
{
var handler = new CapturingHandler();
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("http://localhost:8001")
};
var httpFactory = new Mock<IHttpClientFactory>();
httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient);
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var service = new SummarizerService(httpFactory.Object, memoryCache);
var result = await service.SummarizeSectionAsync("Rewrite this CV", "Professional Summary\nBuilt backend systems.", 1800, 400);
Assert.Equal("rewritten cv", result);
Assert.Equal("/cv/rewrite", handler.LastPath);
Assert.NotNull(handler.LastBody);
Assert.Contains("\"instruction\":\"Rewrite this CV\"", handler.LastBody);
Assert.Contains("\"max_length\":256", handler.LastBody);
Assert.Contains("\"min_length\":180", handler.LastBody);
}
[Fact]
public async Task Summarize_section_clamps_lengths_to_ai_service_limits()
{
var handler = new CapturingHandler();
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("http://localhost:8001")
};
var httpFactory = new Mock<IHttpClientFactory>();
httpFactory.Setup(x => x.CreateClient("ai-service")).Returns(httpClient);
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
var service = new SummarizerService(httpFactory.Object, memoryCache);
await service.SummarizeSectionAsync("Rewrite this CV", "Professional Summary\nBuilt backend systems.", 1800, 400);
Assert.NotNull(handler.LastBody);
Assert.Contains("\"max_length\":256", handler.LastBody);
Assert.Contains("\"min_length\":180", handler.LastBody);
}
private sealed class CapturingHandler : HttpMessageHandler
{
public string? LastBody { get; private set; }
public string? LastPath { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastPath = request.RequestUri?.AbsolutePath;
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
var responseBody = LastPath == "/cv/rewrite"
? "{\"rewritten_text\":\"rewritten cv\"}"
: "{\"summary\":\"ok\"}";
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
};
}
}
}
@@ -13,7 +13,7 @@ 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")
public static JobTrackerContext CreateInMemoryDb(string? userId = "user-1")
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -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)
{
@@ -128,6 +196,10 @@ public sealed class AdminSystemController : ControllerBase
OllamaReachable: null,
OllamaModel: null,
OllamaModelAvailable: null,
OllamaVersion: null,
OllamaInstalledModels: Array.Empty<string>(),
OllamaLoadedModels: Array.Empty<string>(),
OllamaLoadedCount: 0,
HealthLatencyMs: null,
ProbeLatencyMs: null,
LastProbeAt: null,
@@ -211,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;
@@ -56,6 +58,10 @@ namespace JobTrackerApi.Controllers
"DateApplied",
"Location",
"Salary",
"SalaryMin",
"SalaryMax",
"SalaryCurrency",
"SalaryPeriod",
"NextAction",
"FollowUpAt",
"JobUrl",
@@ -74,6 +80,10 @@ namespace JobTrackerApi.Controllers
Esc(j.DateApplied.ToString("o")),
Esc(j.Location),
Esc(j.Salary),
Esc(j.SalaryMin?.ToString(System.Globalization.CultureInfo.InvariantCulture)),
Esc(j.SalaryMax?.ToString(System.Globalization.CultureInfo.InvariantCulture)),
Esc(j.SalaryCurrency),
Esc(j.SalaryPeriod),
Esc(j.NextAction),
Esc(j.FollowUpAt?.ToString("o")),
Esc(j.JobUrl),
File diff suppressed because it is too large Load Diff
@@ -23,8 +23,10 @@ namespace JobTrackerApi.Controllers
private readonly ILogger<JobApplicationsController> _logger;
private readonly ICvTemplateRenderer _cvTemplateRenderer;
private readonly ICvPdfExporter _cvPdfExporter;
private readonly AnalyticsService _analytics;
private readonly IJobCvMatchService _matchService;
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null)
{
_db = db;
_summarizer = summarizer;
@@ -33,6 +35,8 @@ namespace JobTrackerApi.Controllers
_logger = logger;
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
_analytics = analytics ?? new AnalyticsService(db);
_matchService = matchService ?? new JobCvMatchService();
}
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
@@ -749,6 +753,10 @@ Canonical profile:
Deadline: job.Deadline,
Location: job.Location,
Salary: job.Salary,
SalaryMin: job.SalaryMin,
SalaryMax: job.SalaryMax,
SalaryCurrency: job.SalaryCurrency,
SalaryPeriod: job.SalaryPeriod,
NextAction: job.NextAction,
FollowUpAt: job.FollowUpAt,
FeedbackRequestedAt: job.FeedbackRequestedAt,
@@ -1081,6 +1089,10 @@ Canonical profile:
DateTime? Deadline,
string? Location,
string? Salary,
decimal? SalaryMin,
decimal? SalaryMax,
string? SalaryCurrency,
string? SalaryPeriod,
string? NextAction,
DateTime? FollowUpAt,
DateTime? FeedbackRequestedAt,
@@ -1349,6 +1361,10 @@ Canonical profile:
string? Status,
string? Location,
string? Salary,
decimal? SalaryMin,
decimal? SalaryMax,
string? SalaryCurrency,
string? SalaryPeriod,
string? NextAction,
DateTime? FollowUpAt,
string? Notes,
@@ -1367,6 +1383,22 @@ Canonical profile:
bool? HasOtherAttachment
);
private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary(
decimal? min, decimal? max, string? currency, string? period)
{
if (min is < 0) min = null;
if (max is < 0) max = null;
if (min.HasValue && max.HasValue && min > max) (min, max) = (max, min);
var cur = (currency ?? "").Trim().ToUpperInvariant();
if (cur.Length > 8) cur = cur[..8];
var per = (period ?? "").Trim().ToLowerInvariant();
if (per is not ("year" or "month" or "hour")) per = "";
return (min, max, cur.Length == 0 ? null : cur, per.Length == 0 ? null : per);
}
[HttpPost]
public async Task<ActionResult<JobApplication>> Create([FromBody] CreateJobApplicationRequest request, CancellationToken cancellationToken)
{
@@ -1375,9 +1407,7 @@ Canonical profile:
if (title.Length == 0) return BadRequest("Job title is required.");
if (request.CompanyId <= 0) return BadRequest("Valid companyId is required.");
var companyOk = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
if (!companyOk) return BadRequest("companyId does not exist.");
// Scoped by the Company query filter, so this also rejects another user's companyId.
var companyExists = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
if (!companyExists) return BadRequest("companyId does not exist.");
@@ -1386,7 +1416,7 @@ Canonical profile:
OwnerUserId = string.IsNullOrWhiteSpace(userId) ? null : userId,
JobTitle = title,
CompanyId = request.CompanyId,
Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(),
Status = JobPipeline.Normalize(request.Status),
Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(),
Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim(),
NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(),
@@ -1409,6 +1439,9 @@ Canonical profile:
ResponseDate = null,
};
(job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
// Generate and persist a short summary at creation time to avoid repeated model calls.
try
{
@@ -1447,6 +1480,10 @@ Canonical profile:
DateTime? ResponseDate,
string? Location,
string? Salary,
decimal? SalaryMin,
decimal? SalaryMax,
string? SalaryCurrency,
string? SalaryPeriod,
string? NextAction,
DateTime? FollowUpAt,
bool? HasResume,
@@ -1482,11 +1519,13 @@ Canonical profile:
job.JobTitle = title;
job.CompanyId = request.CompanyId;
job.Status = string.IsNullOrWhiteSpace(request.Status) ? job.Status : request.Status.Trim();
job.Status = string.IsNullOrWhiteSpace(request.Status) ? job.Status : JobPipeline.Normalize(request.Status);
job.ResponseReceived = request.ResponseReceived;
job.ResponseDate = request.ResponseDate;
job.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim();
job.Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim();
(job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim();
job.FollowUpAt = request.FollowUpAt;
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
@@ -1533,6 +1572,13 @@ Canonical profile:
public sealed record UpdateStatusRequest(string Status);
public sealed record PipelineStageDto(string Key, int Order, string Category);
/// <summary>Canonical ordered pipeline stages so the UI renders one source of truth.</summary>
[HttpGet("pipeline")]
public ActionResult<IEnumerable<PipelineStageDto>> GetPipeline()
=> Ok(JobPipeline.Stages.Select(s => new PipelineStageDto(s.Key, s.Order, s.Category.ToString())));
[HttpPatch("{id:int}/status")]
public async Task<IActionResult> UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken)
{
@@ -1541,7 +1587,7 @@ Canonical profile:
if (string.IsNullOrWhiteSpace(request.Status)) return BadRequest("Status is required.");
var old = job.Status;
job.Status = request.Status.Trim();
job.Status = JobPipeline.Normalize(request.Status);
if (!string.Equals(old, job.Status, StringComparison.OrdinalIgnoreCase))
{
_db.JobEvents.Add(new JobEvent
@@ -1558,6 +1604,57 @@ Canonical profile:
return NoContent();
}
public sealed record StatusSuggestionDto(
bool HasSuggestion,
string? SuggestedStatus,
string? CurrentStatus,
string? Signal,
string? Confidence,
DateTime? MessageDate,
string? MessageSubject);
/// <summary>
/// Suggests a pipeline status from the job's most recent inbound message (e.g. an interview
/// invite or rejection). Deterministic and always human-confirmed via PATCH .../status.
/// </summary>
[HttpGet("{id:int}/status-suggestion")]
public async Task<ActionResult<StatusSuggestionDto>> GetStatusSuggestion([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var none = new StatusSuggestionDto(false, null, job.Status, null, null, null, null);
var latestInbound = await _db.Correspondences
.AsNoTracking()
.Where(c => c.JobApplicationId == id
&& c.Direction != "outbound"
&& c.From != "Me")
.OrderByDescending(c => c.Date)
.FirstOrDefaultAsync(cancellationToken);
if (latestInbound is null) return Ok(none);
var suggestion = EmailStatusClassifier.Classify(latestInbound.Subject, latestInbound.Content);
if (suggestion is null) return Ok(none);
// Don't nag when the job is already in (or past) the suggested stage.
var currentOrder = JobPipeline.OrderOf(job.Status);
var suggestedOrder = JobPipeline.OrderOf(suggestion.SuggestedStatus);
if (JobPipeline.Normalize(job.Status) == suggestion.SuggestedStatus || currentOrder >= suggestedOrder)
{
return Ok(none);
}
return Ok(new StatusSuggestionDto(
HasSuggestion: true,
SuggestedStatus: suggestion.SuggestedStatus,
CurrentStatus: job.Status,
Signal: suggestion.Signal,
Confidence: suggestion.Confidence,
MessageDate: latestInbound.Date,
MessageSubject: latestInbound.Subject));
}
[HttpPost("{id:int}/refresh-ai")]
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
@@ -1676,6 +1773,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)
@@ -1733,46 +1833,9 @@ Canonical profile:
return Ok(all);
}
public sealed record JobStats(
int Total,
int Active,
int Deleted,
Dictionary<string, int> ByStatus,
int AppliedLast30Days,
double AverageDaysSinceApplied
);
[HttpGet("stats")]
public async Task<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
{
var now = DateTime.Now;
var all = await _db.JobApplications
.AsNoTracking()
.ToListAsync(cancellationToken);
var active = all.Where(j => !j.IsDeleted).ToList();
var byStatus = active
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
var avgDays = active.Count == 0
? 0
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
return Ok(new JobStats(
Total: all.Count,
Active: active.Count,
Deleted: all.Count - active.Count,
ByStatus: byStatus,
AppliedLast30Days: appliedLast30Days,
AverageDaysSinceApplied: Math.Round(avgDays, 1)
));
}
=> Ok(await _analytics.GetStatsAsync(cancellationToken));
public sealed record AnalyticsPoint(string Month, int Applied, int Responses);
[HttpGet("analytics")]
@@ -1969,19 +2032,8 @@ Canonical profile:
return Ok(outList);
}
public sealed record FunnelStagePoint(string Label, int Count);
public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate);
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
public sealed record TagTrendSeries(string Tag, List<int> Counts);
public sealed record TagTrendPoint(string Month, List<int> Counts);
public sealed record AnalyticsOverviewDto(
List<FunnelStagePoint> Funnel,
List<ResponseRatePoint> ResponseRateBySource,
List<CompanyActivityPoint> TopCompanies,
double? MedianDaysToFirstResponse,
int TotalResponses,
int TotalActive
);
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List<string> ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt);
@@ -2067,6 +2119,89 @@ Canonical profile:
};
}
public sealed record MatchScoreDto(
int Score,
string Band,
int MatchedCount,
int TotalKeywords,
List<string> MatchedKeywords,
List<string> MissingKeywords,
List<MatchSectionCoverageDto> SectionCoverage,
bool HasEnoughSignal);
public sealed record MatchSectionCoverageDto(string Section, int Matched, int Total);
// Builds CV text grouped by section so match coverage can show *where* the evidence sits.
private static Dictionary<string, string> BuildCvSections(ApplicationUser? user)
{
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
void Add(string name, IEnumerable<string?> values)
{
var text = string.Join("\n", values.Where(v => !string.IsNullOrWhiteSpace(v)));
if (!string.IsNullOrWhiteSpace(text)) sections[name] = text;
}
Add("Summary", new[] { structured.Contact.Headline }.Concat(structured.Summary));
Add("Skills", structured.Skills);
Add("Experience", structured.Jobs.SelectMany(job =>
new[] { job.Title, job.Company }.Concat(job.Bullets).Concat(job.Skills)));
Add("Education", structured.Education.SelectMany(ed =>
new[] { ed.Qualification, ed.Institution }.Concat(ed.Details)));
// Always include raw profile text (covers users who only pasted plain CV text, and
// catches keywords the structured sections missed).
if (!string.IsNullOrWhiteSpace(user?.ProfileCvText))
{
sections["Profile"] = user!.ProfileCvText!;
}
return sections;
}
/// <summary>
/// Fast, deterministic CV↔job keyword coverage score. Unlike candidate-fit (AI narrative),
/// this makes no model calls, so it returns instantly and reproducibly.
/// </summary>
[HttpGet("{id:int}/match-score")]
public async Task<ActionResult<MatchScoreDto>> GetMatchScore([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvSections = BuildCvSections(user);
if (cvSections.Count == 0)
{
return BadRequest("Add your profile CV on the Profile page before running the match score.");
}
var jobText = string.Join("\n\n", new[] { job.Description, job.TranslatedDescription, job.Notes }
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(jobText))
{
return BadRequest("This job does not have enough description or notes to compare against your CV.");
}
var result = _matchService.Evaluate(job.JobTitle, jobText, cvSections);
return Ok(new MatchScoreDto(
Score: result.Score,
Band: result.Band,
MatchedCount: result.MatchedCount,
TotalKeywords: result.TotalKeywords,
MatchedKeywords: result.MatchedKeywords.ToList(),
MissingKeywords: result.MissingKeywords.ToList(),
SectionCoverage: result.SectionCoverage.Select(s => new MatchSectionCoverageDto(s.Section, s.Matched, s.Total)).ToList(),
HasEnoughSignal: result.HasEnoughSignal));
}
[HttpGet("{id:int}/candidate-fit")]
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
{
@@ -2664,75 +2799,7 @@ Candidate master CV:
[HttpGet("analytics-overview")]
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
{
var activeJobs = await _db.JobApplications
.AsNoTracking()
.Include(j => j.Company)
.Where(j => !j.IsDeleted)
.ToListAsync(cancellationToken);
var funnelMap = new Dictionary<string, int>
{
["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)),
["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)),
["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)),
["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)),
["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)),
};
var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList();
var responseRateBySource = activeJobs
.GroupBy(j => string.IsNullOrWhiteSpace(j.Company?.Source) ? "Unknown source" : j.Company!.Source!.Trim())
.Select(g => new ResponseRatePoint(
g.Key,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Total)
.ThenByDescending(x => x.Rate)
.Take(6)
.ToList();
var topCompanies = activeJobs
.GroupBy(j => new { j.CompanyId, Name = j.Company.Name })
.Select(g => new CompanyActivityPoint(
g.Key.CompanyId,
g.Key.Name,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Count)
.ThenByDescending(x => x.ResponseRate)
.Take(8)
.ToList();
var responseDays = activeJobs
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null)
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays))
.OrderBy(x => x)
.ToList();
double? medianDays = null;
if (responseDays.Count > 0)
{
var mid = responseDays.Count / 2;
medianDays = responseDays.Count % 2 == 0
? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1)
: Math.Round(responseDays[mid], 1);
}
return Ok(new AnalyticsOverviewDto(
Funnel: funnel,
ResponseRateBySource: responseRateBySource,
TopCompanies: topCompanies,
MedianDaysToFirstResponse: medianDays,
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
TotalActive: activeJobs.Count
));
}
=> Ok(await _analytics.GetAnalyticsOverviewAsync(cancellationToken));
[HttpGet("tag-trends")]
public async Task<ActionResult<TagTrendResponse>> GetTagTrends(
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");
+15 -1
View File
@@ -3,17 +3,31 @@ 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
# Retry once after clearing NuGet caches. Transient download corruption on the
# build host can trip NU3008 ("package integrity check failed / has changed since
# it was signed") while restoring a transitive package; clearing the caches and
# re-downloading resolves it.
RUN dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false \
|| ( echo "Publish failed — clearing NuGet caches and retrying once..." \
&& dotnet nuget locals all --clear \
&& dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false )
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
ENV CV_PDF_BROWSER_PATH=/usr/bin/chromium
RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /data
+2 -6
View File
@@ -10,12 +10,8 @@
<ItemGroup>
<Compile Remove="Controllers\**\*.cs" />
<Compile Remove="Services\**\*.cs" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>
+141 -810
View File
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services
{
/// <summary>
/// Read-only analytics/statistics aggregation extracted from JobApplicationsController.
/// Uses the tenant-scoped <see cref="JobTrackerContext"/>, so the global OwnerUserId
/// query filters apply automatically. Behaviour is identical to the former inline
/// controller methods (GetStats / GetAnalyticsOverview).
/// </summary>
public sealed class AnalyticsService
{
private readonly JobTrackerContext _db;
public AnalyticsService(JobTrackerContext db)
{
_db = db;
}
public async Task<JobStats> GetStatsAsync(CancellationToken cancellationToken)
{
var now = DateTime.Now;
// Project to only the columns the stats need instead of materialising full
// JobApplication rows (which drag large Description/TranslatedDescription/
// TailoredCvText/Notes blobs). Aggregation stays in memory over a small
// per-tenant set.
var all = await _db.JobApplications
.AsNoTracking()
.Select(j => new { j.IsDeleted, j.Status, j.DateApplied })
.ToListAsync(cancellationToken);
var active = all.Where(j => !j.IsDeleted).ToList();
var byStatus = active
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
var avgDays = active.Count == 0
? 0
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
return new JobStats(
Total: all.Count,
Active: active.Count,
Deleted: all.Count - active.Count,
ByStatus: byStatus,
AppliedLast30Days: appliedLast30Days,
AverageDaysSinceApplied: Math.Round(avgDays, 1)
);
}
public async Task<AnalyticsOverviewDto> GetAnalyticsOverviewAsync(CancellationToken cancellationToken)
{
// Project to only the fields the overview needs instead of Include-ing full
// Company + JobApplication rows (avoids loading large description/CV blobs).
var activeJobs = await _db.JobApplications
.AsNoTracking()
.Where(j => !j.IsDeleted)
.Select(j => new
{
j.Id,
j.Status,
j.ResponseReceived,
j.ResponseDate,
j.DateApplied,
j.CompanyId,
CompanyName = j.Company.Name,
CompanySource = j.Company.Source
})
.ToListAsync(cancellationToken);
// Funnel = distribution across canonical stages, driven by the pipeline (one source
// of truth, so it includes every stage and normalizes legacy spellings).
var normalizedByStage = activeJobs
.GroupBy(j => JobPipeline.Normalize(j.Status))
.ToDictionary(g => g.Key, g => g.Count());
var funnel = JobPipeline.Stages
.Select(stage => new FunnelStagePoint(stage.Key, normalizedByStage.TryGetValue(stage.Key, out var c) ? c : 0))
.ToList();
var responseRateBySource = activeJobs
.GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim())
.Select(g => new ResponseRatePoint(
g.Key,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Total)
.ThenByDescending(x => x.Rate)
.Take(6)
.ToList();
var topCompanies = activeJobs
.GroupBy(j => new { j.CompanyId, Name = j.CompanyName })
.Select(g => new CompanyActivityPoint(
g.Key.CompanyId,
g.Key.Name,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Count)
.ThenByDescending(x => x.ResponseRate)
.Take(8)
.ToList();
var responseDays = activeJobs
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null)
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays))
.OrderBy(x => x)
.ToList();
double? medianDays = null;
if (responseDays.Count > 0)
{
var mid = responseDays.Count / 2;
medianDays = responseDays.Count % 2 == 0
? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1)
: Math.Round(responseDays[mid], 1);
}
// Time-in-stage: for each active job, when did it enter its current stage? Use the most
// recent StatusChanged event into that stage, else its applied date.
var activeIds = activeJobs.Select(j => j.Id).ToList();
var statusChanges = await _db.JobEvents
.AsNoTracking()
.Where(e => e.Type == "StatusChanged" && activeIds.Contains(e.JobApplicationId))
.Select(e => new { e.JobApplicationId, e.NewValue, e.At })
.ToListAsync(cancellationToken);
var lastEntryByJob = statusChanges
.GroupBy(e => e.JobApplicationId)
.ToDictionary(g => g.Key, g => g.ToList());
var occupancy = activeJobs.Select(job =>
{
var current = JobPipeline.Normalize(job.Status);
DateTime enteredAt = job.DateApplied;
if (lastEntryByJob.TryGetValue(job.Id, out var changes))
{
var lastIntoCurrent = changes
.Where(e => JobPipeline.Normalize(e.NewValue) == current)
.OrderByDescending(e => e.At)
.FirstOrDefault();
if (lastIntoCurrent is not null) enteredAt = lastIntoCurrent.At;
}
return new StageOccupancy(current, enteredAt.ToUniversalTime());
});
var timeInStage = StageAnalytics.TimeInStage(occupancy, DateTime.UtcNow)
.Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count))
.ToList();
return new AnalyticsOverviewDto(
Funnel: funnel,
ResponseRateBySource: responseRateBySource,
TopCompanies: topCompanies,
MedianDaysToFirstResponse: medianDays,
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
TotalActive: activeJobs.Count,
TimeInStage: timeInStage
);
}
}
}
+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);
}
+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
};
@@ -0,0 +1,84 @@
namespace JobTrackerApi.Services
{
public sealed class DatabaseBackupHostedService : BackgroundService
{
private readonly IDatabaseBackupRunner _runner;
private readonly ILogger<DatabaseBackupHostedService> _logger;
private readonly IConfiguration _cfg;
private readonly IStartupReadiness _startupReadiness;
public DatabaseBackupHostedService(
IDatabaseBackupRunner runner,
ILogger<DatabaseBackupHostedService> logger,
IConfiguration cfg,
IStartupReadiness startupReadiness)
{
_runner = runner;
_logger = logger;
_cfg = cfg;
_startupReadiness = startupReadiness;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _startupReadiness.WaitUntilReadyAsync(stoppingToken);
if (!_cfg.GetValue("Backups:Enabled", true))
{
_logger.LogInformation("Automated database backups disabled (Backups:Enabled=false).");
return;
}
if (!_runner.IsSupported)
{
_logger.LogWarning("Automated database backups are unavailable for the configured provider. Configure external backups for MySQL/MariaDB.");
return;
}
var hour = _cfg.GetValue("Backups:HourLocal", 3);
if (hour < 0 || hour > 23) hour = 3;
// Catch-up: guarantee at least one recent backup exists even if the
// process never stays up long enough to reach the scheduled hour.
var latest = _runner.GetLatestBackupUtc();
if (latest is null || latest < DateTime.UtcNow.AddHours(-24))
{
await TryBackupAsync(stoppingToken);
}
while (!stoppingToken.IsCancellationRequested)
{
var now = DateTime.Now;
var next = new DateTime(now.Year, now.Month, now.Day, hour, 0, 0);
if (next <= now) next = next.AddDays(1);
_logger.LogInformation("Next database backup scheduled at {Next}.", next);
try
{
await Task.Delay(next - now, stoppingToken);
}
catch (TaskCanceledException)
{
break;
}
await TryBackupAsync(stoppingToken);
}
}
private async Task TryBackupAsync(CancellationToken ct)
{
try
{
await _runner.RunOnceAsync(ct);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
_logger.LogError(ex, "Database backup failed.");
}
}
}
}
@@ -0,0 +1,110 @@
using Microsoft.Data.Sqlite;
namespace JobTrackerApi.Services
{
public interface IDatabaseBackupRunner
{
string BackupsRoot { get; }
bool IsSupported { get; }
/// <summary>Creates one backup file and prunes old ones. Returns the backup path, or null when unsupported.</summary>
Task<string?> RunOnceAsync(CancellationToken ct);
DateTime? GetLatestBackupUtc();
}
public sealed class SqliteDatabaseBackupRunner : IDatabaseBackupRunner
{
public const string BackupFilePrefix = "jobtracker_backup_";
private readonly ILogger<SqliteDatabaseBackupRunner> _logger;
private readonly string _connectionString;
private readonly int _retainCount;
public string BackupsRoot { get; }
public bool IsSupported { get; }
public SqliteDatabaseBackupRunner(IConfiguration cfg, AppPaths paths, ILogger<SqliteDatabaseBackupRunner> logger)
{
_logger = logger;
var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var cs = cfg.GetConnectionString("JobTracker");
if (string.IsNullOrWhiteSpace(cs))
{
cs = $"Data Source={paths.GetDbPath()}";
provider = "sqlite";
}
_connectionString = cs;
IsSupported = provider == "sqlite";
BackupsRoot = Path.Combine(paths.DataRoot, "backups");
_retainCount = Math.Clamp(cfg.GetValue("Backups:RetainCount", 14), 1, 365);
}
// Test-friendly constructor.
public SqliteDatabaseBackupRunner(string connectionString, string backupsRoot, int retainCount, ILogger<SqliteDatabaseBackupRunner> logger)
{
_logger = logger;
_connectionString = connectionString;
IsSupported = true;
BackupsRoot = backupsRoot;
_retainCount = Math.Clamp(retainCount, 1, 365);
}
public async Task<string?> RunOnceAsync(CancellationToken ct)
{
if (!IsSupported)
{
_logger.LogWarning("Automated backups only support the SQLite provider. Configure external backups for MySQL/MariaDB.");
return null;
}
Directory.CreateDirectory(BackupsRoot);
var target = Path.Combine(BackupsRoot, $"{BackupFilePrefix}{DateTime.UtcNow:yyyyMMdd_HHmmss}.db");
if (File.Exists(target)) File.Delete(target);
await using (var connection = new SqliteConnection(_connectionString))
{
await connection.OpenAsync(ct);
await using var command = connection.CreateCommand();
// VACUUM INTO produces a consistent, compacted snapshot without blocking writers (WAL).
command.CommandText = $"VACUUM INTO '{target.Replace("'", "''")}'";
await command.ExecuteNonQueryAsync(ct);
}
_logger.LogInformation("Database backup written: {File}.", target);
PruneOldBackups();
return target;
}
public DateTime? GetLatestBackupUtc()
{
if (!Directory.Exists(BackupsRoot)) return null;
var latest = ListBackups().FirstOrDefault();
return latest?.LastWriteTimeUtc;
}
private void PruneOldBackups()
{
foreach (var stale in ListBackups().Skip(_retainCount))
{
try
{
stale.Delete();
_logger.LogInformation("Pruned old database backup: {File}.", stale.Name);
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Could not prune old database backup {File}.", stale.Name);
}
}
}
private IOrderedEnumerable<FileInfo> ListBackups()
=> new DirectoryInfo(BackupsRoot)
.EnumerateFiles($"{BackupFilePrefix}*.db")
.OrderByDescending(f => f.LastWriteTimeUtc);
}
}
@@ -0,0 +1,63 @@
using JobTrackerApi.Services;
namespace JobTrackerApi.Services.EmailProviders
{
/// <summary>
/// Gmail implementation of <see cref="IEmailProvider"/>. Adapts the existing
/// <see cref="IGmailOAuthService"/> (Gmail REST client) to the provider-neutral contract,
/// mapping Gmail DTOs to the neutral shapes.
/// </summary>
public sealed class GmailProvider : IEmailProvider
{
private readonly IGmailOAuthService _gmail;
public GmailProvider(IGmailOAuthService gmail)
{
_gmail = gmail;
}
public string ProviderKey => "gmail";
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
return connection is null ? null : new EmailConnectionInfo("gmail", connection.GmailAddress ?? "");
}
public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
var messages = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken)
{
var messages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var attachments = detail.Attachments
.Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.GmailAttachmentId, a.Inline))
.ToList();
return new EmailMessageDetail(
detail.Id,
detail.ThreadId,
detail.Subject,
detail.From,
detail.To,
detail.Date,
detail.Snippet,
detail.BodyText,
detail.BodyHtml,
detail.Labels,
attachments);
}
private static EmailMessageSummary ToSummary(GmailMessageSummary m)
=> new(m.Id, m.ThreadId, m.Subject, m.From, m.To, m.Date, m.Snippet);
}
}
@@ -0,0 +1,69 @@
namespace JobTrackerApi.Services.EmailProviders
{
/// <summary>
/// Provider-neutral email operations so job correspondence can be sourced from Gmail,
/// Microsoft Graph, generic IMAP, or manual/free-text entry behind a single seam.
/// See docs/remaster/PRODUCT_DIRECTION.md (multi-provider email). Gmail is the first
/// implementation (<see cref="GmailProvider"/>); the controller migration and additional
/// providers land in follow-up slices.
/// </summary>
public interface IEmailProvider
{
/// <summary>Stable key: "gmail" | "microsoft" | "imap" | "manual".</summary>
string ProviderKey { get; }
/// <summary>The user's active connection for this provider, or null if not connected.</summary>
Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
/// <summary>Search the user's mailbox. <paramref name="query"/> is provider-specific syntax.</summary>
Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
/// <summary>All messages in a thread/conversation.</summary>
Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken);
/// <summary>Full message content (body + attachments metadata).</summary>
Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
}
public sealed record EmailConnectionInfo(string ProviderKey, string Address);
public sealed record EmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
public sealed record EmailAttachmentRef(string? FileName, string? MimeType, long? SizeBytes, string? ExternalAttachmentId, bool Inline);
public sealed record EmailMessageDetail(
string Id,
string ThreadId,
string Subject,
string From,
string To,
DateTimeOffset? Date,
string Snippet,
string BodyText,
string? BodyHtml,
IReadOnlyList<string> Labels,
IReadOnlyList<EmailAttachmentRef> Attachments);
/// <summary>Resolves a registered <see cref="IEmailProvider"/> by its key.</summary>
public interface IEmailProviderRegistry
{
IReadOnlyList<IEmailProvider> All { get; }
IEmailProvider? Get(string? providerKey);
}
public sealed class EmailProviderRegistry : IEmailProviderRegistry
{
private readonly Dictionary<string, IEmailProvider> _byKey;
public EmailProviderRegistry(IEnumerable<IEmailProvider> providers)
{
All = providers.ToList();
_byKey = All.ToDictionary(p => p.ProviderKey, StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyList<IEmailProvider> All { get; }
public IEmailProvider? Get(string? providerKey)
=> !string.IsNullOrWhiteSpace(providerKey) && _byKey.TryGetValue(providerKey, out var p) ? p : null;
}
}
@@ -0,0 +1,61 @@
namespace JobTrackerApi.Services
{
public sealed record EmailStatusSuggestion(string SuggestedStatus, string Signal, string Confidence);
/// <summary>
/// Deterministic email → pipeline-status classifier. Scans subject/body for outcome signals and
/// suggests a canonical status. No AI: instant, reproducible, and safe (the user always confirms).
/// Priority matters — a rejection email often still mentions "interview", so rejection wins.
/// </summary>
public static class EmailStatusClassifier
{
// Ordered highest-priority first. Each stage lists lowercase phrases to look for.
private static readonly (string Status, string Confidence, string[] Phrases)[] Rules =
{
("Rejected", "high", new[]
{
"regret to inform", "we regret", "unfortunately, we", "not moving forward",
"not be moving forward", "decided not to proceed", "will not be proceeding",
"not to proceed", "not been selected", "will not be progressing",
"unable to offer", "position has been filled", "no longer being considered",
"decided to move forward with other", "pursue other candidates",
"not to move forward", "were not successful", "was not successful",
}),
("Offer", "high", new[]
{
"pleased to offer", "delighted to offer", "happy to offer", "offer of employment",
"job offer", "we would like to offer", "formal offer", "extend an offer",
"offer letter", "excited to offer",
}),
("Interview", "medium", new[]
{
"invite you to interview", "invite you to an interview", "schedule an interview",
"would like to invite you", "phone screen", "phone interview", "video interview",
"technical interview", "next steps in the", "your availability for a call",
"availability for an interview", "set up a call", "set up an interview",
"meet the team", "book a time", "invitation to interview", "interview invitation",
"like to speak with you", "move to the interview",
}),
};
// Weaker single-word cues only fire when no strong phrase matched (kept low-confidence).
private static readonly string[] InterviewWeakCues = { "interview", "assessment", "coding challenge", "take-home" };
public static EmailStatusSuggestion? Classify(string? subject, string? body)
{
var text = $"{subject}\n{body}".ToLowerInvariant();
if (string.IsNullOrWhiteSpace(text)) return null;
foreach (var (status, confidence, phrases) in Rules)
{
var hit = phrases.FirstOrDefault(p => text.Contains(p, StringComparison.Ordinal));
if (hit is not null) return new EmailStatusSuggestion(status, hit, confidence);
}
var weak = InterviewWeakCues.FirstOrDefault(c => text.Contains(c, StringComparison.Ordinal));
if (weak is not null) return new EmailStatusSuggestion("Interview", weak, "low");
return null;
}
}
}
@@ -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) &&
+208
View File
@@ -0,0 +1,208 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using JobTrackerApi.Services.JobImport;
namespace JobTrackerApi.Services
{
/// <summary>One keyword drawn from the job posting and whether the CV covers it.</summary>
public sealed record MatchKeyword(string Keyword, int Weight, bool InTitle, bool Matched);
/// <summary>How many of the matched keywords appear in a given CV section.</summary>
public sealed record MatchSectionCoverage(string Section, int Matched, int Total);
public sealed record JobCvMatchResult(
int Score,
string Band,
int MatchedCount,
int TotalKeywords,
IReadOnlyList<string> MatchedKeywords,
IReadOnlyList<string> MissingKeywords,
IReadOnlyList<MatchSectionCoverage> SectionCoverage,
bool HasEnoughSignal);
public interface IJobCvMatchService
{
JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary<string, string> cvSections);
}
/// <summary>
/// Deterministic CV↔job keyword coverage score. No AI: the same inputs always produce the
/// same number so users get a stable, reproducible signal (the Jobscan-style differentiator).
/// The AI narrative lives separately in the candidate-fit endpoint.
/// </summary>
public sealed class JobCvMatchService : IJobCvMatchService
{
// Weights: curated skill tags are high-signal; salient posting terms are the long tail.
private const int CuratedTagWeight = 3;
private const int TermWeight = 1;
private const int TitleBonus = 2;
private const int MaxKeywords = 28;
private static readonly Regex TokenPattern = new(@"[a-z0-9][a-z0-9+.#-]*", RegexOptions.Compiled);
private static readonly HashSet<string> StopWords = new(StringComparer.OrdinalIgnoreCase)
{
"the", "and", "for", "with", "you", "your", "our", "are", "will", "have", "has", "that",
"this", "from", "not", "but", "all", "can", "who", "how", "why", "what", "when", "who",
"job", "role", "work", "working", "team", "teams", "company", "years", "year", "experience",
"experienced", "skills", "skill", "ability", "able", "strong", "good", "great", "excellent",
"including", "include", "includes", "well", "using", "use", "used", "within", "across",
"into", "onto", "their", "them", "they", "were", "was", "would", "should", "could", "must",
"new", "also", "per", "via", "etc", "such", "any", "one", "two", "three", "day", "days",
"week", "weeks", "month", "months", "time", "full", "part", "based", "join", "looking",
"seeking", "candidate", "candidates", "applicant", "position", "positions", "opportunity",
"responsibilities", "requirements", "required", "preferred", "plus", "nice", "want", "need",
"needs", "help", "make", "made", "get", "got", "more", "most", "many", "much", "each",
"other", "others", "some", "than", "then", "there", "here", "about", "over", "under", "out",
"off", "its", "his", "her", "she", "him", "may", "might", "high", "low", "level", "levels",
"environment", "environments", "world", "people", "person", "customer", "customers", "client",
"clients", "product", "products", "service", "services", "business", "solution", "solutions",
"project", "projects", "process", "processes", "development", "develop", "developer",
// Seniority / role-title words: noise for CV keyword matching (the hard skills are what count).
"senior", "junior", "lead", "principal", "mid", "staff", "engineer", "engineers",
"engineering", "manager", "specialist", "analyst", "consultant", "administrator",
"coordinator", "associate", "intern", "officer", "director", "professional",
};
public JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary<string, string> cvSections)
{
jobTitle ??= string.Empty;
jobText ??= string.Empty;
cvSections ??= new Dictionary<string, string>();
var titleTokens = Tokenize(jobTitle).ToHashSet(StringComparer.OrdinalIgnoreCase);
var keywords = BuildKeywords(jobTitle, jobText, titleTokens);
// Combine all CV sections into one searchable corpus, plus keep per-section text for coverage.
var sectionCorpora = cvSections
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value))
.ToDictionary(kvp => kvp.Key, kvp => Normalize(kvp.Value), StringComparer.OrdinalIgnoreCase);
var fullCorpus = string.Join(" \n ", sectionCorpora.Values);
var evaluated = keywords
.Select(k => k with { Matched = CorpusContains(fullCorpus, k.Keyword) })
.ToList();
var totalWeight = evaluated.Sum(k => k.Weight);
var matchedWeight = evaluated.Where(k => k.Matched).Sum(k => k.Weight);
var hasEnoughSignal = evaluated.Count >= 3 && sectionCorpora.Count > 0;
var score = totalWeight == 0 ? 0 : (int)Math.Round(100.0 * matchedWeight / totalWeight, MidpointRounding.AwayFromZero);
score = Math.Clamp(score, 0, 100);
var band = !hasEnoughSignal ? "Unknown" : score >= 75 ? "Strong" : score >= 50 ? "Partial" : "Low";
var matchedKeywords = evaluated.Where(k => k.Matched)
.OrderByDescending(k => k.Weight).ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
.Select(k => k.Keyword).ToList();
var missingKeywords = evaluated.Where(k => !k.Matched)
.OrderByDescending(k => k.Weight).ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
.Select(k => k.Keyword).ToList();
var sectionCoverage = sectionCorpora
.Select(section => new MatchSectionCoverage(
section.Key,
evaluated.Count(k => CorpusContains(section.Value, k.Keyword)),
evaluated.Count))
.Where(sc => sc.Total > 0)
.OrderByDescending(sc => sc.Matched)
.ToList();
return new JobCvMatchResult(
Score: score,
Band: band,
MatchedCount: matchedKeywords.Count,
TotalKeywords: evaluated.Count,
MatchedKeywords: matchedKeywords,
MissingKeywords: missingKeywords,
SectionCoverage: sectionCoverage,
HasEnoughSignal: hasEnoughSignal);
}
private static List<MatchKeyword> BuildKeywords(string jobTitle, string jobText, HashSet<string> titleTokens)
{
var combined = $"{jobTitle}\n{jobText}";
var byKey = new Dictionary<string, MatchKeyword>(StringComparer.OrdinalIgnoreCase);
// 1) Curated skill tags: high-signal, canonical spelling.
foreach (var tag in SkillTagger.Detect(combined))
{
var inTitle = TitleContains(jobTitle, tag);
byKey[tag] = new MatchKeyword(tag, CuratedTagWeight + (inTitle ? TitleBonus : 0), inTitle, false);
}
// 2) Salient posting terms: frequency-ranked content words from the description.
var frequencies = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var token in Tokenize(jobText))
{
if (token.Length < 3 || StopWords.Contains(token) || IsNumeric(token)) continue;
frequencies[token] = frequencies.TryGetValue(token, out var c) ? c + 1 : 1;
}
var rankedTerms = frequencies
.Where(kvp => kvp.Value >= 1)
.OrderByDescending(kvp => titleTokens.Contains(kvp.Key) ? 1 : 0)
.ThenByDescending(kvp => kvp.Value)
.ThenBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)
.Select(kvp => kvp.Key);
foreach (var term in rankedTerms)
{
if (byKey.Count >= MaxKeywords) break;
if (byKey.ContainsKey(term)) continue;
var inTitle = titleTokens.Contains(term);
byKey[term] = new MatchKeyword(term, TermWeight + (inTitle ? TitleBonus : 0), inTitle, false);
}
return byKey.Values
.OrderByDescending(k => k.Weight)
.ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
.Take(MaxKeywords)
.ToList();
}
private static bool TitleContains(string title, string phrase)
=> Normalize(title).Contains(Normalize(phrase), StringComparison.Ordinal);
private static bool CorpusContains(string normalizedCorpus, string keyword)
{
var needle = Normalize(keyword);
if (needle.Length == 0) return false;
// Word-boundary-ish match to avoid "go" matching "goal".
var idx = normalizedCorpus.IndexOf(needle, StringComparison.Ordinal);
while (idx >= 0)
{
var beforeOk = idx == 0 || !char.IsLetterOrDigit(normalizedCorpus[idx - 1]);
var afterPos = idx + needle.Length;
var afterOk = afterPos >= normalizedCorpus.Length || !char.IsLetterOrDigit(normalizedCorpus[afterPos]);
if (beforeOk && afterOk) return true;
idx = normalizedCorpus.IndexOf(needle, idx + 1, StringComparison.Ordinal);
}
return false;
}
private static IEnumerable<string> Tokenize(string text)
{
if (string.IsNullOrWhiteSpace(text)) yield break;
foreach (Match m in TokenPattern.Matches(text.ToLowerInvariant()))
{
yield return m.Value.Trim('-', '.', '+', '#');
}
}
private static bool IsNumeric(string token)
=> token.All(c => char.IsDigit(c) || c is '.' or '-' or '+');
private static string Normalize(string text)
{
if (string.IsNullOrWhiteSpace(text)) return string.Empty;
var sb = new StringBuilder(text.Length);
foreach (var ch in text.ToLowerInvariant())
{
sb.Append(char.IsWhiteSpace(ch) ? ' ' : ch);
}
return sb.ToString();
}
}
}
@@ -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);
}
}
@@ -9,8 +9,10 @@ public static class SkillTagger
{
private static readonly (string Tag, Regex Pattern, int Weight)[] Patterns =
{
("C#", new Regex(@"\bC#\b|\bcsharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
(".NET", new Regex(@"\b\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
// Symbol skills need punctuation-tolerant boundaries: \b fails next to '#'/'.'
// (both non-word chars), which previously left "C#," and ".NET," undetected.
("C#", new Regex(@"(?<![A-Za-z0-9#])C#|\bc[-\s]?sharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
(".NET", new Regex(@"(?<![A-Za-z0-9.])\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
("Python", new Regex(@"\bPython\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
("Java", new Regex(@"\bJava\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("JavaScript", new Regex(@"\bJavaScript\b|\bJS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
+75
View File
@@ -0,0 +1,75 @@
namespace JobTrackerApi.Services
{
public enum PipelineCategory
{
Active,
Success,
Closed,
}
public sealed record PipelineStage(string Key, int Order, PipelineCategory Category);
/// <summary>
/// Canonical job-application pipeline: the single source of truth for the ordered set of
/// statuses, their grouping, and how free-text/legacy values normalize onto them.
/// Status remains a free-text column so custom values are never destroyed; this only
/// canonicalizes casing and known synonyms.
/// </summary>
public static class JobPipeline
{
public const string DefaultStatus = "Applied";
public static readonly IReadOnlyList<PipelineStage> Stages = new List<PipelineStage>
{
new("Applied", 1, PipelineCategory.Active),
new("Waiting", 2, PipelineCategory.Active),
new("Interview", 3, PipelineCategory.Active),
new("Offer", 4, PipelineCategory.Success),
new("Rejected", 5, PipelineCategory.Closed),
new("Ghosted", 6, PipelineCategory.Closed),
};
private static readonly Dictionary<string, string> Canonical =
Stages.ToDictionary(s => s.Key, s => s.Key, StringComparer.OrdinalIgnoreCase);
// Legacy/synonym spellings that should collapse onto a canonical stage.
private static readonly Dictionary<string, string> Aliases = new(StringComparer.OrdinalIgnoreCase)
{
["interviewing"] = "Interview",
["interviews"] = "Interview",
["interviewed"] = "Interview",
["in interview"] = "Interview",
["awaiting response"] = "Waiting",
["awaiting"] = "Waiting",
["in progress"] = "Waiting",
["pending"] = "Waiting",
["no response"] = "Ghosted",
["no reply"] = "Ghosted",
["declined"] = "Rejected",
};
/// <summary>
/// Returns the canonical status for a raw value: trims, matches a stage case-insensitively,
/// or maps a known synonym. Unknown non-empty values are preserved (trimmed) so custom
/// statuses survive. Empty/whitespace becomes the default stage.
/// </summary>
public static string Normalize(string? status)
{
var trimmed = (status ?? string.Empty).Trim();
if (trimmed.Length == 0) return DefaultStatus;
if (Canonical.TryGetValue(trimmed, out var canonical)) return canonical;
if (Aliases.TryGetValue(trimmed, out var alias)) return alias;
return trimmed;
}
public static bool IsCanonical(string? status)
=> !string.IsNullOrWhiteSpace(status) && Canonical.ContainsKey(status.Trim());
public static int OrderOf(string? status)
{
var normalized = Normalize(status);
var stage = Stages.FirstOrDefault(s => string.Equals(s.Key, normalized, StringComparison.OrdinalIgnoreCase));
return stage?.Order ?? int.MaxValue; // custom statuses sort last
}
}
}
@@ -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;
}
}
+134 -22
View File
@@ -1,4 +1,5 @@
using Microsoft.Playwright;
using System.Diagnostics;
using System.Text;
namespace JobTrackerApi.Services;
@@ -11,6 +12,18 @@ public interface ICvPdfExporter
public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
{
private static readonly string[] BrowserCandidates =
{
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable"
};
private readonly AppPaths _paths;
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
@@ -25,42 +38,141 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
var now = DateTimeOffset.UtcNow;
var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd"));
Directory.CreateDirectory(folder);
var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName)
? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf"
: renderResult.SuggestedFileName;
var storagePath = Path.Combine(folder, fileName);
var tempRoot = Path.Combine(Path.GetTempPath(), "jobtracker-cv-pdf", Guid.NewGuid().ToString("n"));
var htmlPath = Path.Combine(tempRoot, "document.html");
var userDataDir = Path.Combine(tempRoot, "profile");
Directory.CreateDirectory(tempRoot);
Directory.CreateDirectory(userDataDir);
try
{
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
await File.WriteAllTextAsync(htmlPath, renderResult.Html ?? string.Empty, Encoding.UTF8, cancellationToken);
var browserPath = ResolveBrowserPath();
if (string.IsNullOrWhiteSpace(browserPath))
{
Headless = true,
});
var page = await browser.NewPageAsync();
await page.SetContentAsync(renderResult.Html, new PageSetContentOptions
throw new InvalidOperationException("CV PDF export is unavailable. Install Chromium/Google Chrome or set CV_PDF_BROWSER_PATH.");
}
var arguments = BuildArguments(userDataDir, storagePath, htmlPath);
var startInfo = new ProcessStartInfo();
startInfo.FileName = browserPath;
startInfo.Arguments = arguments;
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.UseShellExecute = false;
startInfo.CreateNoWindow = true;
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
await process.WaitForExitAsync(cancellationToken);
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
if (process.ExitCode != 0)
{
WaitUntil = WaitUntilState.Load,
});
var bytes = await page.PdfAsync(new PagePdfOptions
throw new InvalidOperationException($"CV PDF export failed via browser CLI. ExitCode={process.ExitCode}. Stdout={stdout}. Stderr={stderr}");
}
if (!File.Exists(storagePath))
{
Format = "A4",
PrintBackground = true,
Margin = new()
{
Top = "0",
Right = "0",
Bottom = "0",
Left = "0",
}
});
await File.WriteAllBytesAsync(storagePath, bytes, cancellationToken);
throw new InvalidOperationException($"CV PDF export did not create the expected file at {storagePath}.");
}
var bytes = await File.ReadAllBytesAsync(storagePath, cancellationToken);
return new CvPdfArtifact(fileName, storagePath, bytes);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export CV PDF to {Path}", storagePath);
throw new InvalidOperationException("CV PDF export is unavailable. Ensure Chromium is installed for Playwright on this machine.", ex);
throw;
}
finally
{
TryDeleteDirectory(tempRoot);
}
}
private static string BuildArguments(string userDataDir, string storagePath, string htmlPath)
{
var parts = new List<string>
{
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--disable-dev-shm-usage",
"--allow-file-access-from-files",
"--enable-local-file-accesses",
"--user-data-dir=" + Quote(userDataDir),
"--print-to-pdf=" + Quote(storagePath),
Quote(htmlPath)
};
return string.Join(' ', parts);
}
private static string? ResolveBrowserPath()
{
var configured = Environment.GetEnvironmentVariable("CV_PDF_BROWSER_PATH");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
return configured;
}
foreach (var candidate in BrowserCandidates)
{
if (Path.IsPathRooted(candidate))
{
if (File.Exists(candidate)) return candidate;
continue;
}
var resolved = FindOnPath(candidate);
if (!string.IsNullOrWhiteSpace(resolved)) return resolved;
}
return null;
}
private static string? FindOnPath(string fileName)
{
var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
var parts = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var dir in parts)
{
var fullPath = Path.Combine(dir, fileName);
if (File.Exists(fullPath)) return fullPath;
}
return null;
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
}
catch
{
// best effort temp cleanup
}
}
private static string Quote(string value)
{
return '"' + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + '"';
}
}
+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);
+45
View File
@@ -0,0 +1,45 @@
namespace JobTrackerApi.Services
{
public sealed record StageDurationPoint(string Stage, int Order, double MedianDays, int Count);
/// <summary>One job's position: its canonical stage and when it entered that stage.</summary>
public sealed record StageOccupancy(string Status, DateTime EnteredStageAtUtc);
/// <summary>
/// Pure time-in-stage analytics: for each active pipeline stage, the median number of days
/// the jobs currently sitting there have been waiting. Closed stages (Rejected/Ghosted) and
/// the terminal success stage (Offer) are excluded — "how long has this been stuck" only
/// makes sense for stages you still act on.
/// </summary>
public static class StageAnalytics
{
public static List<StageDurationPoint> TimeInStage(IEnumerable<StageOccupancy> jobs, DateTime nowUtc)
{
var byStage = jobs
.Select(j => (Stage: JobPipeline.Normalize(j.Status), Days: Math.Max(0, (nowUtc - j.EnteredStageAtUtc).TotalDays)))
.Where(x => JobPipeline.Stages.Any(s => s.Key == x.Stage && s.Category == PipelineCategory.Active))
.GroupBy(x => x.Stage);
var points = new List<StageDurationPoint>();
foreach (var group in byStage)
{
var days = group.Select(x => x.Days).OrderBy(x => x).ToList();
points.Add(new StageDurationPoint(
Stage: group.Key,
Order: JobPipeline.OrderOf(group.Key),
MedianDays: Median(days),
Count: days.Count));
}
return points.OrderBy(p => p.Order).ToList();
}
private static double Median(IReadOnlyList<double> sorted)
{
if (sorted.Count == 0) return 0;
var mid = sorted.Count / 2;
var median = sorted.Count % 2 == 0 ? (sorted[mid - 1] + sorted[mid]) / 2d : sorted[mid];
return Math.Round(median, 1);
}
}
}
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);
}
}
+200 -8
View File
@@ -25,6 +25,10 @@ namespace JobTrackerApi.Services
bool? OllamaReachable,
string? OllamaModel,
bool? OllamaModelAvailable,
string? OllamaVersion,
IReadOnlyList<string>? OllamaInstalledModels,
IReadOnlyList<string>? OllamaLoadedModels,
int? OllamaLoadedCount,
double? HealthLatencyMs,
double? ProbeLatencyMs,
DateTimeOffset? LastProbeAt,
@@ -66,10 +70,16 @@ 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 const int AiServiceMaxSummaryLength = 256;
private const int AiServiceMaxMinLength = 180;
private const int AiServiceMinSummaryLength = 24;
private const int AiServiceMinMinLength = 8;
private readonly IHttpClientFactory _httpFactory;
private readonly IMemoryCache _cache;
private readonly object _metricsLock = new();
@@ -105,6 +115,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;
@@ -114,13 +153,37 @@ 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()}";
return SummarizeCoreAsync(composed, maxLength, minLength);
return RewriteCoreAsync(instruction.Trim(), text.Trim(), maxLength, minLength);
}
private async Task<string?> SummarizeCoreAsync(string text, int maxLength, int minLength)
private static string ComposeBoundedPrompt(string instruction, string text)
{
var key = BuildCacheKey(text, maxLength, minLength);
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?> RewriteCoreAsync(string instruction, string text, int maxLength, int minLength)
{
var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength);
var normalizedMinLength = Math.Clamp(minLength, AiServiceMinMinLength, AiServiceMaxMinLength);
if (normalizedMinLength >= normalizedMaxLength)
{
normalizedMinLength = Math.Max(AiServiceMinMinLength, normalizedMaxLength - 1);
}
var composed = ComposeBoundedPrompt(instruction, text);
var key = BuildCacheKey($"rewrite::{composed}", normalizedMaxLength, normalizedMinLength);
Interlocked.Increment(ref _requests);
if (_cache.TryGetValue<string>(key, out var cached))
@@ -137,7 +200,95 @@ namespace JobTrackerApi.Services
Interlocked.Increment(ref _cacheMisses);
var client = _httpFactory.CreateClient("ai-service");
var payload = JsonSerializer.Serialize(new { text, max_length = maxLength, min_length = minLength });
var payload = JsonSerializer.Serialize(new
{
instruction,
text,
max_length = normalizedMaxLength,
min_length = normalizedMinLength,
});
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
var sw = Stopwatch.StartNew();
try
{
var res = await client.PostAsync("/cv/rewrite", content);
sw.Stop();
Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks);
if (!res.IsSuccessStatusCode)
{
var errorBody = await ReadErrorBodyAsync(res);
Interlocked.Increment(ref _failures);
lock (_metricsLock)
{
_lastFailureAt = DateTimeOffset.UtcNow;
_lastError = $"AI rewrite failed: {errorBody}";
}
return null;
}
using var stream = await res.Content.ReadAsStreamAsync();
using var doc = await JsonDocument.ParseAsync(stream);
if (doc.RootElement.TryGetProperty("rewritten_text", out var el))
{
var s = el.GetString();
if (!string.IsNullOrWhiteSpace(s)) _cache.Set(key, s, TimeSpan.FromHours(6));
lock (_metricsLock)
{
_lastSuccessAt = DateTimeOffset.UtcNow;
_lastError = null;
}
return s;
}
lock (_metricsLock)
{
_lastFailureAt = DateTimeOffset.UtcNow;
_lastError = "AI rewrite failed: response did not contain rewritten_text.";
}
return null;
}
catch (Exception ex)
{
sw.Stop();
Interlocked.Add(ref _totalLatencyTicks, sw.ElapsedTicks);
Interlocked.Increment(ref _failures);
lock (_metricsLock)
{
_lastFailureAt = DateTimeOffset.UtcNow;
_lastError = ex.Message;
}
return null;
}
}
private async Task<string?> SummarizeCoreAsync(string text, int maxLength, int minLength)
{
var normalizedMaxLength = Math.Clamp(maxLength, AiServiceMinSummaryLength, AiServiceMaxSummaryLength);
var normalizedMinLength = Math.Clamp(minLength, AiServiceMinMinLength, AiServiceMaxMinLength);
if (normalizedMinLength >= normalizedMaxLength)
{
normalizedMinLength = Math.Max(AiServiceMinMinLength, normalizedMaxLength - 1);
}
var key = BuildCacheKey(text, normalizedMaxLength, normalizedMinLength);
Interlocked.Increment(ref _requests);
if (_cache.TryGetValue<string>(key, out var cached))
{
Interlocked.Increment(ref _cacheHits);
lock (_metricsLock)
{
_lastSuccessAt = DateTimeOffset.UtcNow;
_lastError = null;
}
return cached;
}
Interlocked.Increment(ref _cacheMisses);
var client = _httpFactory.CreateClient("ai-service");
var payload = JsonSerializer.Serialize(new { text, max_length = normalizedMaxLength, min_length = normalizedMinLength });
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
var sw = Stopwatch.StartNew();
@@ -146,7 +297,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);
@@ -202,11 +363,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;
}
@@ -263,11 +425,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;
}
@@ -318,9 +481,15 @@ namespace JobTrackerApi.Services
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
{
@@ -340,10 +509,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
{
@@ -406,6 +594,10 @@ namespace JobTrackerApi.Services
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"
]
},
@@ -1,99 +0,0 @@
{
"Version": "dailyexport.v1",
"CreatedAt": "2026-03-25T02:00:00.0368687+01:00",
"Companies": [
{
"Id": 1,
"OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c",
"Name": "Acme Browser QA",
"Location": null,
"Source": null,
"RecruiterName": "Maria Recruiter",
"RecruiterEmail": "maria@acme.test",
"RecruiterLinkedIn": null,
"LastContactedAt": "2026-03-24T11:15:21.4772436",
"NextContactAt": "2026-03-24T00:00:00",
"PipelineStage": null
}
],
"JobApplications": [
{
"Id": 1,
"OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c",
"JobTitle": "Backend Developer",
"CompanyId": 1,
"Company": null,
"Status": "Waiting",
"DateApplied": "2026-03-01T13:00:00+01:00",
"Location": null,
"Salary": null,
"NextAction": null,
"FollowUpAt": "2026-03-24T00:00:00",
"FeedbackRequestedAt": null,
"RecruiterMessageDraft": "Saved browser recruiter message",
"HasResume": true,
"HasCoverLetter": true,
"HasPortfolio": false,
"HasOtherAttachment": false,
"IsDeleted": false,
"DeletedAt": null,
"ResponseReceived": true,
"ResponseDate": null,
"Notes": "Browser-seeded notes\n\n\u003C\u003C\u003CAPPLICATION_ANSWER_DRAFT\u003E\u003E\u003E\nSaved browser application answer\n\u003C\u003C\u003CEND_APPLICATION_ANSWER_DRAFT\u003E\u003E\u003E",
"CoverLetterText": "Saved browser cover letter",
"JobUrl": "https://example.test/backend-developer",
"Description": "Need .NET APIs and strong stakeholder communication.",
"TranslatedDescription": null,
"DescriptionLanguage": null,
"Tags": "[\u0022.NET\u0022, \u0022APIs\u0022, \u0022Communication\u0022]",
"Deadline": null,
"ShortSummary": "Strong overlap in backend API delivery.",
"TailoredCvText": "Saved browser tailored CV",
"TailoredCvUpdatedAt": "2026-03-24T10:58:13.226164+01:00",
"LastReminderEmailSentAt": null,
"Messages": [],
"Attachments": [],
"Events": [],
"DaysSince": 23
}
],
"Correspondence": [
{
"Id": 1,
"JobApplicationId": 1,
"From": "Company",
"Subject": "Backend Developer application update",
"Channel": "Email",
"ExternalMessageId": "browser-msg-1",
"ExternalThreadId": "browser-thread-1",
"ExternalFrom": "Maria Recruiter \u003Cmaria@acme.test\u003E",
"ExternalTo": "admin@example.com",
"Content": "We are aligning interview slots and need someone who can own the API layer.",
"Date": "2026-03-10T10:00:00+01:00"
},
{
"Id": 2,
"JobApplicationId": 1,
"From": "Me",
"Subject": "Re: Backend Developer application update",
"Channel": "Email",
"ExternalMessageId": null,
"ExternalThreadId": null,
"ExternalFrom": null,
"ExternalTo": null,
"Content": "Hi Maria,\n\nEdited browser follow-up.\n\nThanks,\nadmin@example.com",
"Date": "2026-03-24T11:15:21.4521755"
}
],
"Attachments": [],
"Events": [],
"Rules": {
"Id": 1,
"AppliedFollowUpDays": 14,
"AppliedGhostDays": 30,
"OfferFollowUpDays": 7,
"OfferGhostDays": 14,
"FeedbackFollowUpDays": 7,
"FeedbackGhostDays": 14
}
}
@@ -1,99 +0,0 @@
{
"Version": "dailyexport.v1",
"CreatedAt": "2026-03-26T02:00:00.005823+01:00",
"Companies": [
{
"Id": 1,
"OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c",
"Name": "Acme Browser QA",
"Location": null,
"Source": null,
"RecruiterName": "Maria Recruiter",
"RecruiterEmail": "maria@acme.test",
"RecruiterLinkedIn": null,
"LastContactedAt": "2026-03-24T11:15:21.4772436",
"NextContactAt": "2026-03-24T00:00:00",
"PipelineStage": null
}
],
"JobApplications": [
{
"Id": 1,
"OwnerUserId": "23dc196b-f227-4499-93fe-403d8801e21c",
"JobTitle": "Backend Developer",
"CompanyId": 1,
"Company": null,
"Status": "Waiting",
"DateApplied": "2026-03-01T13:00:00+01:00",
"Location": null,
"Salary": null,
"NextAction": null,
"FollowUpAt": "2026-03-24T00:00:00",
"FeedbackRequestedAt": null,
"RecruiterMessageDraft": "Saved browser recruiter message",
"HasResume": true,
"HasCoverLetter": true,
"HasPortfolio": false,
"HasOtherAttachment": false,
"IsDeleted": false,
"DeletedAt": null,
"ResponseReceived": true,
"ResponseDate": null,
"Notes": "Browser-seeded notes\n\n\u003C\u003C\u003CAPPLICATION_ANSWER_DRAFT\u003E\u003E\u003E\nSaved browser application answer\n\u003C\u003C\u003CEND_APPLICATION_ANSWER_DRAFT\u003E\u003E\u003E",
"CoverLetterText": "Saved browser cover letter",
"JobUrl": "https://example.test/backend-developer",
"Description": "Need .NET APIs and strong stakeholder communication.",
"TranslatedDescription": null,
"DescriptionLanguage": null,
"Tags": "[\u0022.NET\u0022, \u0022APIs\u0022, \u0022Communication\u0022]",
"Deadline": null,
"ShortSummary": "Strong overlap in backend API delivery.",
"TailoredCvText": "Saved browser tailored CV",
"TailoredCvUpdatedAt": "2026-03-24T10:58:13.226164+01:00",
"LastReminderEmailSentAt": null,
"Messages": [],
"Attachments": [],
"Events": [],
"DaysSince": 24
}
],
"Correspondence": [
{
"Id": 1,
"JobApplicationId": 1,
"From": "Company",
"Subject": "Backend Developer application update",
"Channel": "Email",
"ExternalMessageId": "browser-msg-1",
"ExternalThreadId": "browser-thread-1",
"ExternalFrom": "Maria Recruiter \u003Cmaria@acme.test\u003E",
"ExternalTo": "admin@example.com",
"Content": "We are aligning interview slots and need someone who can own the API layer.",
"Date": "2026-03-10T10:00:00+01:00"
},
{
"Id": 2,
"JobApplicationId": 1,
"From": "Me",
"Subject": "Re: Backend Developer application update",
"Channel": "Email",
"ExternalMessageId": null,
"ExternalThreadId": null,
"ExternalFrom": null,
"ExternalTo": null,
"Content": "Hi Maria,\n\nEdited browser follow-up.\n\nThanks,\nadmin@example.com",
"Date": "2026-03-24T11:15:21.4521755"
}
],
"Attachments": [],
"Events": [],
"Rules": {
"Id": 1,
"AppliedFollowUpDays": 14,
"AppliedGhostDays": 30,
"OfferFollowUpDays": 7,
"OfferGhostDays": 14,
"FeedbackFollowUpDays": 7,
"FeedbackGhostDays": 14
}
}
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<key id="9a89a42c-d2bd-4770-83fb-5930685432db" version="1">
<creationDate>2026-03-24T09:54:28.8487759Z</creationDate>
<activationDate>2026-03-24T09:54:28.8487759Z</activationDate>
<expirationDate>2026-06-22T09:54:28.8487759Z</expirationDate>
<descriptor deserializerType="Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel.AuthenticatedEncryptorDescriptorDeserializer, Microsoft.AspNetCore.DataProtection, Version=9.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
<descriptor>
<encryption algorithm="AES_256_CBC" />
<validation algorithm="HMACSHA256" />
<masterKey p4:requiresEncryption="true" xmlns:p4="http://schemas.asp.net/2015/03/dataProtection">
<!-- Warning: the key below is in an unencrypted form. -->
<value>LXbXqbpiEXn0OM6fr/TuXDBcZd83DvOInTI09PGZRr1Z20LQCD/PUKF1oo9UwC4O1VgK3wA//yxH9PPCIPzEaw==</value>
</masterKey>
</descriptor>
</descriptor>
</key>
@@ -27,7 +27,6 @@
<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>
+33
View File
@@ -0,0 +1,33 @@
using System.Collections.Generic;
namespace JobTrackerApi.Models
{
// Read-only analytics/statistics response DTOs. Extracted from
// JobApplicationsController so the aggregation logic can live in AnalyticsService.
public sealed record JobStats(
int Total,
int Active,
int Deleted,
Dictionary<string, int> ByStatus,
int AppliedLast30Days,
double AverageDaysSinceApplied
);
public sealed record FunnelStagePoint(string Label, int Count);
public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate);
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
public sealed record StageDurationDto(string Stage, double MedianDays, int Count);
public sealed record AnalyticsOverviewDto(
List<FunnelStagePoint> Funnel,
List<ResponseRatePoint> ResponseRateBySource,
List<CompanyActivityPoint> TopCompanies,
double? MedianDaysToFirstResponse,
int TotalResponses,
int TotalActive,
List<StageDurationDto> TimeInStage
);
}
+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;
}
+6
View File
@@ -13,6 +13,12 @@ public class JobApplication
public DateTime DateApplied { get; set; } = DateTime.UtcNow;
public string? Location { get; set; }
public string? Salary { get; set; }
// Structured salary; the free-text Salary field is kept for display/back-compat.
public decimal? SalaryMin { get; set; }
public decimal? SalaryMax { get; set; }
public string? SalaryCurrency { get; set; } // e.g. "NOK", "GBP", "EUR"
public string? SalaryPeriod { get; set; } // "year" | "month" | "hour"
public string? NextAction { get; set; }
public DateTime? FollowUpAt { get; set; }
public DateTime? FeedbackRequestedAt { get; set; }
+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; }
+279 -13
View File
@@ -67,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();
@@ -132,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
{
@@ -165,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)
@@ -299,6 +321,8 @@ public static class StructuredCvProfileJson
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;
@@ -421,10 +445,24 @@ public static class StructuredCvProfileJson
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 = 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);
@@ -438,12 +476,41 @@ public static class StructuredCvProfileJson
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();
@@ -512,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)
@@ -573,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)
@@ -584,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)
{
@@ -604,8 +754,26 @@ public static class StructuredCvProfileJson
level = remainder.NullIfWhitespace();
}
}
else
{
var dashMatch = Regex.Match(normalized, @"^(?<name>[\p{L}][\p{L}\s-]+?)\s*[-]\s*(?<level>.+)$");
if (dashMatch.Success)
{
name = dashMatch.Groups["name"].Value.Trim();
level = dashMatch.Groups["level"].Value.Trim();
}
else
{
var parenMatch = Regex.Match(normalized, @"^(?<name>[\p{L}][\p{L}\s-]+?)\s*\((?<level>.+)\)$");
if (parenMatch.Success)
{
name = parenMatch.Groups["name"].Value.Trim();
level = parenMatch.Groups["level"].Value.Trim();
}
}
}
var normalizedLevel = HumanLanguageCatalog.ExtractLevel(level) ?? HumanLanguageCatalog.ExtractLevel(item);
var normalizedLevel = HumanLanguageCatalog.ExtractLevel(level) ?? HumanLanguageCatalog.ExtractLevel(normalized);
return new StructuredCvLanguage
{
Name = normalizedLevel is not null ? HumanLanguageCatalog.NormalizeLanguageName(name) : null,
@@ -632,11 +800,20 @@ public static class StructuredCvProfileJson
if (lines[0].StartsWith("###", StringComparison.Ordinal)) lines[0] = lines[0].TrimStart('#', ' ');
job.Title = lines[0].NullIfWhitespace();
var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line)).ToList();
var dateValue = metadata.Select(line => Regex.Match(line, @"(?:(?:\w+\s+)?\d{4}|Present|Current)(?:\s*[-]\s*(?:(?:\w+\s+)?\d{4}|Present|Current))?", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null);
if (!string.IsNullOrWhiteSpace(dateValue))
var titleDateMatch = Regex.Match(job.Title ?? string.Empty, @"(?<title>.+?)\s*[-]\s*(?<start>(?:\d{1,2}/)?\d{4})\s*(?:to|[-])\s*(?<end>(?:\d{1,2}/)?\d{4}|Present|Current)$", RegexOptions.IgnoreCase);
if (titleDateMatch.Success)
{
var parts = Regex.Split(dateValue, "\\s*[-]\\s*");
job.Title = titleDateMatch.Groups["title"].Value.NullIfWhitespace();
job.Start = titleDateMatch.Groups["start"].Value.NullIfWhitespace();
job.End = titleDateMatch.Groups["end"].Value.NullIfWhitespace();
job.IsCurrent = string.Equals(job.End, "present", StringComparison.OrdinalIgnoreCase) || string.Equals(job.End, "current", StringComparison.OrdinalIgnoreCase);
}
var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line)).ToList();
var dateValue = metadata.Select(line => Regex.Match(line, @"(?:(?:\d{1,2}/)?\d{4}|Present|Current)(?:\s*(?:[-]|to)\s*(?:(?:\d{1,2}/)?\d{4}|Present|Current))?", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null);
if (!string.IsNullOrWhiteSpace(dateValue) && string.IsNullOrWhiteSpace(job.Start))
{
var parts = Regex.Split(dateValue, "\\s*(?:[-]|to)\\s*");
job.Start = parts.FirstOrDefault().NullIfWhitespace();
job.End = parts.Skip(1).FirstOrDefault().NullIfWhitespace();
job.IsCurrent = string.Equals(job.End, "present", StringComparison.OrdinalIgnoreCase) || string.Equals(job.End, "current", StringComparison.OrdinalIgnoreCase);
@@ -655,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);
@@ -692,9 +891,76 @@ public static class StructuredCvProfileJson
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);
+13 -1
View File
@@ -12,6 +12,8 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re
- History/event trail per application (created, status changes, follow-up set, delete/restore)
- Export jobs to JSON/CSV + daily scheduled JSON export
- Optional “job import” preview from supported job sites (plugins) + optional translation to English
- Quick-capture bookmarklet (Settings) + installable PWA with a mobile share-target: both open `/?add=<page url>` to pre-fill Add Job from any posting
- Note: no offline service-worker cache is bundled by design (the app is deployed frequently; an aggressive cache would risk serving stale builds). The manifest provides installability and share-to-capture without it.
- Optional local AI service for short/full descriptions
- Optional Google sign-in (Google ID tokens) to protect the API
@@ -133,6 +135,10 @@ Common keys:
- `Exports:DailyEnabled`: enable/disable daily export background job
- `Exports:DailyFolder`: export destination (relative to `Data:Root` if not absolute)
- `Exports:DailyHourLocal`: local hour (023) when the daily export runs
- `Backups:Enabled`: enable/disable the automated SQLite backup job (default `true`)
- `Backups:HourLocal`: local hour (023) when the daily database backup runs (default `3`)
- `Backups:RetainCount`: how many backup files to keep in `<Data:Root>/backups` (default `14`)
- Backups use SQLite `VACUUM INTO` (consistent snapshot, safe with WAL). A catch-up backup runs at startup when none exists from the last 24 h. For MySQL/MariaDB configure external backups instead (see `deploy/MARIADB.md`).
- `Auth:GoogleClientId`: if set, enables JWT bearer validation for Google ID tokens
- `Auth:JwtKey`: secret used to sign local JWTs for username/password login (set via env var `Auth__JwtKey`)
- `Auth:JwtIssuer`: JWT issuer (default `JobTrackerApi`)
@@ -187,7 +193,9 @@ Authentication:
- Updates an application; records a `StatusChanged` event if the status changed.
- `PATCH /api/jobapplications/{id}/status`
- Body: `{ "status": "..." }`
- Updates only status; records `StatusChanged` if it changed.
- Updates only status; records `StatusChanged` if it changed. The status is normalized against the canonical pipeline (casing + known synonyms like `Interviewing``Interview`); unrecognized values are preserved as custom statuses.
- `GET /api/jobapplications/pipeline`
- Returns the canonical ordered pipeline stages (`Applied, Waiting, Interview, Offer, Rejected, Ghosted`) with display order and category (`Active`/`Success`/`Closed`). The UI renders board columns and status dropdowns from this single source of truth.
- `PATCH /api/jobapplications/{id}/followup`
- Body: `{ "followUpAt": "2026-03-13T12:00:00Z" }` (or `null`)
- Sets/clears follow-up date; records a `FollowUpSet` event.
@@ -197,6 +205,10 @@ Authentication:
- Returns a unified timeline combining job events, correspondence, and attachments.
- `GET /api/jobapplications/stats`
- Returns totals, counts by status, applied-last-30-days, and average days since applied.
- `GET /api/jobapplications/{id}/match-score`
- Deterministic CV↔job keyword-coverage score (0100) with matched/missing keywords and per-CV-section coverage. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is `GET /api/jobapplications/{id}/candidate-fit`.)
- `GET /api/jobapplications/{id}/status-suggestion`
- Deterministic status suggestion derived from the job's most recent inbound message (interview invite / offer / rejection). Returns a forward-only suggestion (`hasSuggestion`, `suggestedStatus`, `signal`, …) or `hasSuggestion: false`. Applying it is a normal `PATCH .../status` — always user-confirmed.
- `DELETE /api/jobapplications/{id}`
- Soft-deletes an application (`IsDeleted=true`); records a `Deleted` event.
- `POST /api/jobapplications/{id}/restore`
+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.
+66 -8
View File
@@ -22,28 +22,49 @@ fi
export APP_VERSION="${APP_VERSION:-0.0.0}"
export APP_COMMIT_SHA="${APP_COMMIT_SHA:-unknown}"
export APP_BUILD_STAMP="${APP_BUILD_STAMP:-unknown}"
export DEPLOY_BUILD_AI_SERVICE="${DEPLOY_BUILD_AI_SERVICE:-false}"
compose() {
docker compose "$@"
}
build_with_recovery() {
if compose build; then
build_core_with_recovery() {
if compose build backend frontend; then
return 0
fi
echo "docker compose build failed. Attempting one cleanup + retry because layer extraction can fail on constrained hosts."
echo "docker compose build for core services failed. Attempting one cleanup + retry because layer extraction can fail on constrained hosts."
docker builder prune -af >/dev/null 2>&1 || true
docker system prune -f >/dev/null 2>&1 || true
compose build --no-cache backend frontend
}
build_ai_with_recovery() {
if compose build ai-service; then
return 0
fi
echo "docker compose build for ai-service failed. Attempting one cleanup + retry because layer extraction can fail on constrained hosts."
docker image rm -f app-ai-service:latest 2>/dev/null || true
docker builder prune -af >/dev/null 2>&1 || true
docker system prune -f >/dev/null 2>&1 || true
compose build --no-cache ai-service
compose build backend frontend
}
compose pull || true
build_with_recovery
build_core_with_recovery
if [ "$DEPLOY_BUILD_AI_SERVICE" = "true" ]; then
build_ai_with_recovery
else
echo "Skipping ai-service rebuild during deploy (set DEPLOY_BUILD_AI_SERVICE=true to rebuild it)."
fi
# Force recreation so updated port mappings, env vars, and container config always apply on deploy.
compose up -d --force-recreate --remove-orphans
compose up -d --force-recreate --remove-orphans backend frontend
if [ "$DEPLOY_BUILD_AI_SERVICE" = "true" ]; then
# Ollama is opt-in (compose "bundled-ollama" profile). Deploys reuse an
# existing/shared Ollama via OLLAMA_BASE_URL instead of starting a duplicate.
compose up -d --force-recreate ai-service
fi
if [ -n "${OLLAMA_MODEL:-}" ]; then
echo "Post-deploy Ollama warmup enabled for model: ${OLLAMA_MODEL}"
@@ -62,9 +83,46 @@ fi
ai_status="$(compose ps ai-service --format '{{.State}}' 2>/dev/null | head -n 1 | tr '[:upper:]' '[:lower:]')"
if [ "$ai_status" != "running" ]; then
echo "AI service is not healthy after deploy (state: ${ai_status:-unknown})."
echo "AI service is not healthy after deploy (state: ${ai_status:-unknown}). Continuing because AI is not a deploy gate for the core app."
compose logs --tail=200 ai-service || true
exit 1
fi
if [ -n "${APP_PUBLIC_BASE_URL:-}" ]; then
public_base="${APP_PUBLIC_BASE_URL%/}"
auth_config_body_file="$(mktemp)"
auth_config_headers_file="$(mktemp)"
cleanup_public_check() {
rm -f "$auth_config_body_file" "$auth_config_headers_file"
}
trap cleanup_public_check EXIT
echo "Running public smoke check against ${public_base}"
if ! curl -fsS "${public_base}/" >/dev/null; then
echo "Public frontend check failed for ${public_base}/"
exit 1
fi
if ! curl -fsS -D "$auth_config_headers_file" -o "$auth_config_body_file" "${public_base}/api/auth/config"; then
echo "Public API smoke check failed for ${public_base}/api/auth/config"
exit 1
fi
content_type="$(awk 'BEGIN{IGNORECASE=1} /^content-type:/ {print $2}' "$auth_config_headers_file" | tr -d '\r' | tail -n 1)"
if [[ "$content_type" != application/json* ]]; then
echo "Public API smoke check returned unexpected content type: ${content_type:-missing}"
echo "First bytes of response:"
head -c 200 "$auth_config_body_file" || true
exit 1
fi
if ! grep -q 'requireAuth' "$auth_config_body_file"; then
echo "Public API smoke check returned JSON without requireAuth."
cat "$auth_config_body_file"
exit 1
fi
trap - EXIT
cleanup_public_check
fi
# Clean up old legacy container name if it still exists from pre-rename deployments.
+18 -2
View File
@@ -54,6 +54,9 @@ services:
frontend:
build:
context: ./job-tracker-ui
# fork-ts-checker (CRA's build type-checker) needs more than Docker's default
# 64MB /dev/shm; too little causes a SIGSEGV during `npm run build`.
shm_size: '1gb'
args:
- REACT_APP_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
# Optional override; default in production is `/api`
@@ -72,12 +75,21 @@ services:
context: ./tools/summarizer
dockerfile: Dockerfile
environment:
# Point at an existing/shared Ollama by setting OLLAMA_BASE_URL in .env
# (e.g. http://<host-ip>:11435). The in-compose ollama service below is
# opt-in via the "bundled-ollama" profile, so it is NOT started by default
# and no duplicate Ollama container is created.
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b}
# AI provider for heavy /cv/* calls: ollama (default) | gemini | groq.
# Set AI_PROVIDER=gemini + GEMINI_API_KEY in prod to offload a weak local GPU.
- AI_PROVIDER=${AI_PROVIDER:-ollama}
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.0-flash}
- GROQ_API_KEY=${GROQ_API_KEY:-}
- GROQ_MODEL=${GROQ_MODEL:-llama-3.3-70b-versatile}
ports:
- "8001:8001"
depends_on:
- ollama
networks:
- default
- shared_services
@@ -88,7 +100,11 @@ services:
timeout: 10s
retries: 3
# Opt-in only: start with `docker compose --profile bundled-ollama up`.
# Left out of the default set so deploys reuse an existing/shared Ollama
# (configured via OLLAMA_BASE_URL) instead of spinning up a duplicate.
ollama:
profiles: ["bundled-ollama"]
image: ollama/ollama:latest
ports:
- "11434:11434"
+78
View File
@@ -0,0 +1,78 @@
# Merge Request — Wave 0 quick wins + Tier-1/Tier-2 features
**Branch:** `chore/wave0-quick-wins``main`
**Scope:** 24 commits · 62 files · +3,165 / 489
**Status:** all tests green (backend 135, frontend 23 suites / 54 tests), production build compiles.
> Prepared for human review. Do **not** auto-merge. One operator action is required after merge
> (DataProtection key rotation — see *Known limitations*).
---
## Summary
Delivers the first two roadmap tiers plus the engineering-health groundwork, developed as small
conventional commits. Two design principles run through it:
1. **Deterministic over "AI-guessy."** Match scoring, status suggestions, and pipeline logic are
pure/deterministic — instant, reproducible, and safe (the user confirms every state change). This
directly answers the market's most common complaint (hallucinated/generic AI output).
2. **One pathway, not two.** The bookmarklet and the PWA share-target feed a single `/?add=` capture
flow rather than parallel implementations.
## What's included
**Engineering health (Wave 0)**
- `security:` untracked committed DataProtection keys + daily exports; removed dead legacy controllers.
- `feat:` automated daily SQLite backups (`VACUUM INTO`, retention, startup catch-up) — prod previously
had **no** automated backup on Linux.
- `ci:` run the **entire** frontend suite (the old whitelist was hiding 3 broken suites, now fixed).
- `feat:` dev-only OpenAPI at `/openapi/v1.json`; `feat:` structured salary fields.
**Tier-1 features**
- **Match score** (`GET /jobapplications/{id}/match-score`) — deterministic CV↔job keyword coverage
(0100) + matched/missing keywords + section coverage. Instant panel on the Candidate Fit tab.
- **Canonical pipeline** — `JobPipeline` single source of truth; status normalized on write (custom
values preserved); UI deduped across 5 files; `GET .../pipeline`.
- **Analytics v2** — time-in-stage medians (from `StatusChanged` history) + funnel driven by the
pipeline (fixes a bug that omitted the Waiting stage).
- **Status suggestions** — deterministic email→status classifier surfaced as a human-confirmed banner.
**Tier-2 features**
- **Bookmarklet** quick-capture (Settings) reusing `jobimport/preview`.
- **Installable PWA** with a mobile share-target into the same capture flow.
**Quality**
- Phase-6 security review (`docs/SECURITY_REPORT.md`): tenant isolation on new endpoints verified +
regression-tested; no injection/ReDoS; dev-only OpenAPI.
- Bug fixes: `SkillTagger` C#/.NET regex (silently missed those skills everywhere), a React
stale-closure, a duplicated DB query, and 3 pre-existing hidden test failures.
## Test coverage added
New pure/unit-tested services: `JobCvMatchService` (7), `JobPipeline` (14), `StageAnalytics` (4),
`EmailStatusClassifier` (7). New endpoint integration + authorization tests (match-score,
status-suggestion). New frontend tests: match-score panel, status-suggestion banner, pipeline,
quick-capture, capture-url resolution.
## Docs
New: `docs/SYSTEM_OVERVIEW.md`, `docs/PRODUCT_RESEARCH.md`, `docs/ROADMAP.md`,
`docs/SECURITY_REPORT.md`. README updated with the new endpoints, backup/pipeline config, and
quick-capture/PWA notes.
## Known limitations / follow-ups
- **ACTION REQUIRED (security):** the removed DataProtection key XMLs remain in git **history**.
Rotate them on the production host after merge (see `SECURITY_REPORT.md` §6).
- **Per-user custom pipeline stages** were deliberately deferred (unproven demand; large surface).
- **No offline service worker** by design — the app deploys frequently and an aggressive cache would
risk serving stale builds. The PWA is installable and share-capable without it.
- Not yet done (future branches): interview hub (M3), contacts CRM (M4), god-controller decomposition,
performance pass, Vite migration.
## Reviewer notes
- Repo quirk: controllers/services compile via the `JobTrackerBackend` library, **not** the
`JobTrackerApi` host project (see `docs/SYSTEM_OVERVIEW.md` §2).
- All AI-adjacent features are deterministic and make no model calls.
+121
View File
@@ -0,0 +1,121 @@
# PRODUCT_RESEARCH.md — Job Application Tracking Market (2026)
> Phase 2 deliverable. Research conducted 2026-07-02 via web sources (linked throughout).
> Purpose: position Jobbjakt against the market and rank the features worth building next.
---
## 1. Market landscape
The market splits into five clusters:
| Cluster | Representatives | Model |
|---|---|---|
| **Tracker-first + AI resume** | [Teal](https://www.tealhq.com/), [Huntr](https://huntr.co/pricing), JibberJobber | Freemium SaaS; premium $2940/mo |
| **Autofill / volume** | [Simplify](https://simplify.jobs/job-application-tracker) (autofill), [LazyApply](https://lazyapply.com/) ($99999/yr), LoopCV (auto-apply) | Extension-centric |
| **Matching + copilot** | [Jobright](https://jobright.ai/blog/teal-review-2026-walkthrough-alternatives-and-faqs/) | AI job matching, resume tailoring, autofill |
| **Resume/ATS optimization** | [Jobscan](https://www.jobscan.co/) ($49.95/mo!), Resume Worded, Rezi | Match-score per job description |
| **Self-hosted / privacy** | [JobSync](https://github.com/Gsync/jobsync), [CareerSync](https://github.com/Tomiwajin/CareerSync), [career-ops](https://career-ops.org/), various [GitHub projects](https://github.com/topics/job-application-tracker) | OSS, local-first, often Ollama-based |
| **Email auto-tracking** | [Trackr](https://www.trackrjobs.com/), [G-Track](https://jobtrack-ai.com/gmail-job-tracker), Gmail [Chrome extensions](https://chromewebstore.google.com/detail/gmail-job-application-tra/lkpjngmdfncejiomkofogfdoppgifmkh) | Inbox scanning → status updates |
### Competitor snapshots
**Teal** — market leader for tracker+resume. Free: unlimited tracking, Chrome extension (50+ job boards), kanban (Saved/Applied/Interview/Offer/Rejected), 10 ATS templates, contact manager, ATS score (15 checks). Premium ($9/wk, $29/mo, [$79/qtr](https://www.tealhq.com/pricing)): keyword match scoring, AI bullets/cover letters, analytics. Cons reported: [billing-after-cancellation complaints, generic/hallucinating AI content, ATS failures on two-column templates](https://resumehog.com/blog/posts/teal-hq-review-april-2026-is-the-job-tracker-worth-your-time.html), [high-maintenance workflow, overwhelming UI, poor support](https://resumejudge.com/blog/tealhq-review/), no automation.
**Huntr** — best visual kanban + CRM layer. Free: 100 tracked jobs cap, unlimited base resumes, basic scoring. [Pro $40/mo](https://huntr.co/pricing): AI tailored resumes, unlimited cover letters, advanced matching/insights. 4.9★ extension (clip from any site + autofill). Cons: [must rebuild resume inside their builder, plain templates, free plan stops being useful fast](https://resumejudge.com/blog/huntr-review/), online-only.
**Simplify** — free autofill extension for 100+ ATS portals (Workday, Greenhouse, iCIMS), real-time keyword flagging, pipeline tracking. Execution-focused, light on CRM depth.
**Jobscan** — per-job resume match score (1100, 30+ checks, "aim ≥75%"), cover-letter optimization report. Expensive ($49.95/mo). This single feature is the most-cited reason people pay for job-search tools.
**Email auto-trackers** (Trackr, G-Track, extensions) — scan Gmail, AI-classify (Applied/Next step/Rejected/Offer), auto-update statuses, apply labels. This is rapidly becoming table stakes; users love "zero manual data entry".
**Self-hosted OSS** (JobSync, CareerSync, career-ops) — privacy pitch ("no cloud, no telemetry, no account"), Ollama/local-LLM parsing, but all are far less complete than Jobbjakt: mostly CRUD + basic AI, no CV pipeline, no correspondence CRM, no rules engine.
### Standard vs premium features across the market
- **Table stakes (free everywhere):** kanban board, status stages, notes, basic contact tracking, browser clipper, export.
- **Premium (what people pay for):** per-job resume↔JD **match scoring with keyword gaps**, AI tailored resumes/cover letters, analytics (response rate, funnel conversion, time-in-stage), email/interview follow-up automation, autofill at scale.
- **Emerging differentiators:** inbox auto-tracking, interview prep hubs (question banks, scheduling, calendar sync — cf. [interview scheduling tools](https://www.selectsoftwarereviews.com/buyer-guide/interview-scheduling-software)), job-match scoring against a profile, salary/offer comparison.
### Recurring user frustrations (opportunities)
1. **Privacy/data anxiety** — sensitive career data on VC-funded SaaS; [breach/misuse concerns](https://www.saashub.com/compare-job-tracker-by-teal-vs-huntr). Jobbjakt's core moat.
2. **Paywall fatigue** — free tiers cap exactly at the point of seriousness (Huntr's 100 jobs, Teal's AI credits, Jobscan's 5 scans/mo).
3. **AI slop** — hallucinated skills, misspelled names, generic bullets; users want AI grounded in *their* real CV (Jobbjakt's structured-CV grounding is the right architecture).
4. **Manual data entry** — retyping jobs and statuses; solved by clippers + inbox scanning.
5. **Vendor lock-in** — resumes trapped in proprietary builders (Huntr), hard exports.
6. **Tool sprawl** — tracker + Jobscan + resume builder + calendar = 4 subscriptions; users want one hub.
---
## 2. Feature matrix — Jobbjakt vs market
✅ has it · 🟡 partial · ❌ missing
| Feature | Teal | Huntr | Simplify | OSS self-hosted | **Jobbjakt today** |
|---|---|---|---|---|---|
| Kanban pipeline | ✅ | ✅ | ✅ | 🟡 | 🟡 board view exists; status is free-text, no drag-drop canonical pipeline |
| Job capture from URL | ✅ ext | ✅ ext | ✅ ext | 🟡 | 🟡 server-side parse (Finn/NAV/LinkedIn/Jobbnorge + JSON-LD); no extension/bookmarklet |
| Inbox auto-tracking | ❌ | ❌ | 🟡 | 🟡 | ✅ **Gmail OAuth import + human review queue** (ahead of paid SaaS) |
| Contacts/recruiter CRM | ✅ | ✅ | ❌ | ❌ | 🟡 company-level only, no people entities |
| Resume/CV builder | ✅ | ✅ | 🟡 | ❌ | ✅ structured CV parse + templates + PDF export |
| Per-job tailored resume (AI) | 💰 | 💰 | 💰 | ❌ | ✅ **local-AI tailored drafts** (privacy-unique) |
| Resume↔JD match score + keyword gaps | 💰 | 💰 | 🟡 | ❌ | ❌ (handoff doc lists "missing-keyword analysis" as planned) |
| AI cover letters / messages | 💰 | 💰 | 💰 | ❌ | ✅ free, local |
| Follow-up reminders | ✅ | ✅ | 🟡 | ❌ | ✅ + rules engine (auto-ghost) — richer than most |
| Analytics dashboard (funnel, response rate, time-in-stage) | 💰 | 💰 | 🟡 | 🟡 | 🟡 basic stats endpoint only |
| Interview management (schedule, prep notes, calendar) | 🟡 | 🟡 | ❌ | ❌ | ❌ (only generic follow-up dates) |
| Calendar integration (ICS/Google) | 🟡 | 🟡 | ❌ | ❌ | ❌ |
| Salary/offer tracking & comparison | 🟡 | 🟡 | ❌ | ❌ | 🟡 salary text field only |
| Autofill applications | ❌ | ✅ | ✅ | ❌ | ❌ (out of scope — needs extension) |
| Multi-language (EN/NB) + translation | ❌ | ❌ | ❌ | ❌ | ✅ unique for Nordic market |
| Self-hosted / data ownership | ❌ | ❌ | ❌ | ✅ | ✅ |
| Mobile experience | ✅ apps | ✅ | ✅ | ❌ | 🟡 responsive-ish desktop web; no PWA |
| Export/portability | 🟡 | 🟡 | 🟡 | ✅ | ✅ JSON/CSV + daily export |
**Position:** Jobbjakt is already **ahead of every OSS competitor** and matches or beats paid SaaS on AI drafting, Gmail import, and data ownership. Its gaps versus paid SaaS are: match scoring, canonical pipeline/kanban UX, interview & calendar layer, analytics depth, capture friction (no extension), and contact-level CRM.
---
## 3. Market gap — what would make Jobbjakt significantly better than existing solutions
> **"The private, self-hosted career hub: everything Teal+Huntr+Jobscan charge $7090/mo for, powered by your own local AI, with your data never leaving your server."**
No product today combines: serious tracker UX + inbox auto-tracking + local-LLM tailoring + match scoring + interview hub, self-hosted. Jobbjakt is uniquely ~60% of the way there.
---
## 4. Ranked feature ideas (value × effort)
Effort: S (<1 day) · M (13 days) · L (12 wk) · XL (>2 wk). Grounded in the Phase 1 codebase map.
| # | Feature | User impact | Effort | Notes |
|---|---|---|---|---|
| 1 | **CV↔job match score + keyword gap analysis** (per job: score, missing keywords, section coverage; reuse structured CV JSON + existing Ollama path) | ★★★★★ — the #1 paid feature in the market, free & local here | ML | Backend has all inputs already; add endpoint + UI panel in job workspace |
| 2 | **Canonical pipeline + drag-drop kanban** (status enum/ordering, custom stages per user, drive board/badges from it) | ★★★★★ — core daily UX; free-text status blocks analytics too | ML | Already on README wish list; needs migration for status normalization |
| 3 | **Analytics dashboard v2** (funnel conversion, response rate, time-in-stage, weekly activity, source effectiveness) | ★★★★ — retention feature; needs #2 for clean stages | M | Data all exists in `JobEvent` history |
| 4 | **Interview hub** (interview entity: rounds, type, scheduled time, prep notes, outcome; ICS feed/export + reminders) | ★★★★ — biggest functional gap vs SaaS | L | New entity + timeline integration; ICS is cheap, Google Calendar sync later |
| 5 | **Bookmarklet / minimal browser capture** (one-click "save to Jobbjakt" using existing `jobimport/preview`) | ★★★★ — kills the biggest friction (manual entry); full extension can wait | SM | Server parsing already exists; a bookmarklet or share-target PWA is days not weeks |
| 6 | **Contacts (people) CRM** (recruiter/hiring-manager entities linked to companies/jobs/correspondence) | ★★★ | M | Natural extension of company recruiter fields |
| 7 | **PWA pass** (installable, mobile nav polish, share-target for job URLs) | ★★★ — mobile is where users check status | M | CRA supports PWA manifest; pairs with #5 |
| 8 | **Salary/offer tracker** (structured salary min/max/currency, offer comparison view) | ★★ | SM | Currently a free-text field |
| 9 | **Smarter inbox** (extend existing Gmail review with AI status suggestions: "this looks like a rejection → move to Rejected?") | ★★★★ — compounds an existing unique strength | M | Classification via existing Ollama service |
| 10 | **Web push / digest notifications** (beyond SMTP) | ★★ | M | Needs service worker (pairs with #7) |
Deliberately **not** recommended: auto-apply bots (ToS/ethics/quality problems, LazyApply-style tools are poorly reviewed), building a full Chrome-store extension now (high maintenance; bookmarklet first), multi-provider cloud AI (undermines the privacy moat — keep local-first with optional cloud later).
## 5. Recommended implementation order (input to Phase 3 roadmap)
1. **Match score + keyword gaps** (#1) — flagship differentiator, builds on freshest code (structured CV).
2. **Canonical pipeline + kanban** (#2) — unblocks analytics, fixes daily UX.
3. **Analytics v2** (#3) — quick follow-on.
4. **Bookmarklet capture** (#5) + **PWA** (#7) — friction killers.
5. **Interview hub** (#4) — biggest new surface, schedule after the above land.
6. Then #9, #6, #8, #10 by appetite.
Engineering-health work (CI test whitelist, prod DB backups, god-controller decomposition) is tracked separately in `docs/SYSTEM_OVERVIEW.md` §1517 and should interleave with feature work in Phase 3.
---
Sources: [Prentus tracker roundup](https://prentus.com/blog/we-found-the-5-best-job-tracker-tools-on-the-market) · [ApplyArc comparison](https://applyarc.com/compare/best-job-application-trackers) · [Teal pricing](https://www.tealhq.com/pricing) · [Teal reviews (ResumeHog)](https://resumehog.com/blog/posts/teal-hq-review-april-2026-is-the-job-tracker-worth-your-time.html) · [Teal cons (ResumeJudge)](https://resumejudge.com/blog/tealhq-review/) · [Huntr pricing](https://huntr.co/pricing) · [Huntr cons (ResumeJudge)](https://resumejudge.com/blog/huntr-review/) · [Huntr vs Teal](https://huntr.co/blog/huntr-vs-teal) · [Simplify tracker](https://simplify.jobs/job-application-tracker) · [Jobright review of Teal](https://jobright.ai/blog/teal-review-2026-walkthrough-alternatives-and-faqs/) · [LazyApply](https://lazyapply.com/) · [Auto-apply tools compared](https://blog.fastapply.co/auto-apply-jobs-tools-compared-2026) · [Jobscan](https://www.jobscan.co/) · [Jobscan pricing](https://onlineatschecker.com/blog/jobscan-pricing-2026-free-plan-worth-it) · [JobSync (OSS)](https://github.com/Gsync/jobsync) · [CareerSync (OSS)](https://github.com/Tomiwajin/CareerSync) · [career-ops](https://career-ops.org/) · [Trackr](https://www.trackrjobs.com/) · [G-Track](https://jobtrack-ai.com/gmail-job-tracker) · [Gmail tracker extension](https://chromewebstore.google.com/detail/gmail-job-application-tra/lkpjngmdfncejiomkofogfdoppgifmkh) · [Interview scheduling software guide](https://www.selectsoftwarereviews.com/buyer-guide/interview-scheduling-software) · [SaaSHub Teal vs Huntr](https://www.saashub.com/compare-job-tracker-by-teal-vs-huntr)
+75
View File
@@ -0,0 +1,75 @@
# ROADMAP.md — Jobbjakt Product & Engineering Roadmap
> Phase 3 deliverable (2026-07-02). Sources: `docs/SYSTEM_OVERVIEW.md` (Phase 1) and `docs/PRODUCT_RESEARCH.md` (Phase 2).
> Scoring: Value/Complexity/Risk on ▲ high / ● medium / ▽ low. Effort: S <1 day · M 13 days · L 12 wk · XL >2 wk.
**North star:** the private, self-hosted career hub — the tracker UX of Huntr, the tailoring/scoring of Teal+Jobscan, powered by local AI, with data that never leaves your server.
---
## Tier 0 — Quick Wins (do first; days, low risk, compounding payoff)
| # | Item | Type | Value | Effort | Risk | Rationale |
|---|---|---|---|---|---|---|
| Q1 | **CI: run the full frontend test suite** (replace the hand-maintained 10-file whitelist with the whole suite; fix/quarantine any flaky test explicitly) | eng | ▲ | S | ▽ | New tests currently silently skipped in CI; already caused a gap once |
| Q2 | **Automated production DB backup** (scheduled SQLite `VACUUM INTO`/copy to `exports/` with retention; document restore) | eng | ▲ | SM | ▽ | Prod currently has *no working automated backup* (backup endpoint is Windows-DPAPI-only, prod is Linux) |
| Q3 | **Repo hygiene** (delete dead root `Controller/`; remove `temp_job.json`, `temp_post_job.py`; gitignore `JobTrackerApi/CvArtifacts/`, `bin_build/`, stray artifacts; commit pending WIP fixes on a branch) | eng | ● | S | ▽ | Removes footguns before refactors; working tree currently dirty |
| Q4 | **Swagger/OpenAPI** (Swashbuckle or built-in OpenAPI, dev-only exposure) | eng | ● | S | ▽ | README endpoint list already drifts; prerequisite for a generated TS client later |
| Q5 | **Structured salary fields** (min/max/currency/period alongside the free-text field, backfill-friendly) | product | ● | SM | ▽ | Cheap now, prerequisite for offer comparison + analytics later |
## Tier 1 — High Value (the differentiators; next 24 weeks of feature work)
| # | Item | Value | Effort | Risk | Notes |
|---|---|---|---|---|---|
| H1 | **CV↔Job match score + keyword gap analysis** — per-job score, missing keywords, section coverage; reuse `ProfileCvStructureJson` + existing Ollama path; panel in job workspace | ▲▲ | ML | ● | The market's #1 paid feature (Jobscan $50/mo), free & local here. Flagship differentiator |
| H2 | **Canonical pipeline + drag-drop kanban** — status enum + ordering + per-user custom stages; migration normalizing existing free-text statuses; board becomes drag-drop | ▲▲ | ML | ● | Fixes daily UX; unblocks H3; the riskiest part is the status migration (needs careful mapping + tests) |
| H3 | **Analytics dashboard v2** — funnel conversion, response rate, time-in-stage, weekly activity, source effectiveness (data already in `JobEvent`) | ▲ | M | ▽ | Depends on H2 for clean stages |
| H4 | **Gmail AI status suggestions** — extend the existing review queue: classify incoming mail (rejection/interview/offer) via local AI and suggest status moves, human-confirmed | ▲ | M | ● | Compounds an existing unique strength; keep human-in-the-loop |
## Tier 2 — Medium Value (after Tier 1)
| # | Item | Value | Effort | Risk |
|---|---|---|---|---|
| M1 | **Bookmarklet / PWA share-target capture** — one-click save-to-Jobbjakt reusing `jobimport/preview` | ▲ | SM | ▽ |
| M2 | **PWA pass** — manifest, installability, mobile nav polish | ● | M | ▽ |
| M3 | **Interview hub** — interview entity (round, type, time, prep notes, outcome), timeline integration, ICS export + reminders | ▲ | L | ● |
| M4 | **Contacts (people) CRM** — recruiter/hiring-manager entities linked to companies/jobs/correspondence | ● | M | ▽ |
| M5 | **Durable CV processing queue** — DB-backed queue replacing in-memory (jobs survive restart) | ● | M | ● |
| M6 | **ProblemDetails + validation consistency** across API | ● | M | ▽ |
## Tier 3 — Long-Term Improvements (structural; interleave carefully)
| # | Item | Value | Effort | Risk |
|---|---|---|---|---|
| L1 | **Decompose god controllers** (`JobApplicationsController` 151 KB, `ProfileCvController` 117 KB, `GmailController` 60 KB) into feature services; extract AI prompt construction behind interfaces. Strictly behavior-preserving, test-first, one slice per PR | ▲ (maintainability) | XL | ▲ |
| L2 | **Finish the project-layout migration** — physically move linked `Models/`/`Data/`/controller/service files into real projects, retire glob-include `JobTrackerBackend` | ● | L | ● |
| L3 | **Vite migration** (CRA/react-scripts is EOL; 4 GB-heap builds) | ● | L | ● |
| L4 | **OpenAPI-generated TypeScript client** replacing hand-written `api.ts` surface | ● | ML | ● |
| L5 | **Staging environment / deploy gate** (compose profile or second host; smoke test before prod) | ▲ (ops) | L | ● |
## Tier 4 — Future Ideas (not scheduled)
- Full browser extension (Chrome/Firefox store) with autofill.
- Web push notifications + weekly digest.
- Company research assistant (local AI summarizing company info).
- Offer comparison & salary analytics dashboards.
- Job feed matching from saved searches (Finn/NAV polling).
- Native mobile wrappers; CalDAV/Google Calendar two-way sync.
- Multi-instance/scale-out readiness (distributed cache/queue).
---
## Recommended execution sequence (Phase 4+)
Interleaving product and engineering so debt never blocks features:
1. **Wave 0 (hygiene):** Q3 → Q1 → Q2 → Q4 → Q5 (each a small conventional commit on a feature branch; Q1/Q2 are the two items with real operational risk today)
2. **Wave 1 (flagship):** H1 match scoring (design doc → backend endpoint → UI panel → tests)
3. **Wave 2 (core UX):** H2 canonical pipeline/kanban, then H3 analytics
4. **Wave 3:** H4 Gmail suggestions, M1 bookmarklet, M2 PWA
5. **Wave 4:** M3 interview hub, M4 contacts, M5 durable queue
6. **Continuous:** L1 controller decomposition proceeds opportunistically — whenever a wave touches a god-controller area, extract that slice first (M6 rides along); L2L5 scheduled after Wave 3 checkpoint.
Phases 510 of the mission (bug hunt, security audit, performance, refactoring, testing, docs) run after or between waves as checkpoints; Phase 11 rules apply throughout (feature branches, conventional commits, full test suite before commit, no auto-merge to main).
**Explicitly deprioritized:** auto-apply automation (quality/ToS problems), cloud AI providers (undermines privacy moat), Chrome-store extension before the bookmarklet proves demand.
+122
View File
@@ -0,0 +1,122 @@
# SECURITY_REPORT.md — Session Change Review
> Phase 6 deliverable. Scope: security review of the changes made in this work session
> (Wave 0 + roadmap H1H4), plus confirmation that the tenant-isolation model still holds.
> Date: 2026-07-03. Complements the prior standalone assessments in
> `docs/security-assessments/` (M013 adversarial, M014 remediation, M015 authorization replay).
This is **not** a full re-audit of the whole application — those live in `docs/security-assessments/`.
It is a focused review of the new/changed surface so nothing shipped this session introduces a regression.
---
## 1. Summary
No new vulnerabilities were introduced. Tenant isolation on the new endpoints is carried by the
existing `JobTrackerContext` global query filters and is now covered by regression tests. One latent
correctness issue (a routable background-service method) was closed, and leaked runtime secrets were
removed from version control (rotation recommended — see §6).
| Severity | Count | Items |
|---|---|---|
| Critical | 0 | — |
| High | 0 | — |
| Medium | 1 (mitigated) | DataProtection keys present in git history (untracked this session; rotation recommended) |
| Low / hardening | 3 | see §5 |
---
## 2. New/changed attack surface reviewed
| Change | Surface | Verdict |
|---|---|---|
| `GET /jobapplications/{id}/match-score` | route int id; reads own CV + job | Safe — tenant-scoped |
| `GET /jobapplications/{id}/status-suggestion` | route int id; reads own correspondence | Safe — tenant-scoped |
| `GET /jobapplications/pipeline` | none (static metadata) | Safe |
| `PATCH .../status`, Create/Update (status normalization) | user string → `JobPipeline.Normalize` | Safe — no injection, values stored parameterized |
| Structured salary fields | numeric + short strings, `NormalizeSalary` | Safe — clamps negatives, whitelists period |
| Automated DB backup (`VACUUM INTO`) | server-controlled path | Safe — see §4 |
| Dev OpenAPI (`/openapi/v1.json`) | schema | Safe — `Development` environment only |
| `EmailStatusClassifier` | reads stored correspondence text | Safe — deterministic, no eval/injection |
---
## 3. OWASP-oriented checklist for the new code
- **A01 Broken Access Control** — The two new data endpoints load the job via
`_db.JobApplications.FirstOrDefaultAsync(j => j.Id == id)`, which is filtered by the global
query filter `CurrentUserId != null && OwnerUserId == CurrentUserId` (deny-on-null, hardened in
M013-2). A cross-user id returns `NotFound`, not another tenant's data. The correspondence lookup
in `status-suggestion` and the `JobEvent` lookup in analytics are likewise filtered through their
parent's owner. **Verified by `JobApplicationsAuthorizationTests` (match-score + status-suggestion).**
- **A03 Injection** — All new persistence goes through EF Core parameterized queries. The only raw
SQL added is `VACUUM INTO '<path>'` with a fully server-derived path (see §4). No string
concatenation of user input into queries.
- **A03 ReDoS** — New regexes (`JobCvMatchService.TokenPattern`, the revised `SkillTagger` C#/.NET
patterns with fixed-width look-behinds) are linear with no catastrophic backtracking.
- **A04 Insecure Design** — Status suggestions and match scoring are deterministic and
**human-confirmed** (a status only changes when the user clicks). No automated outbound actions.
- **A05 Security Misconfiguration** — OpenAPI is exposed only under `IsDevelopment()`; production
deployments (`ASPNETCORE_ENVIRONMENT=Production`) do not serve it.
- **A08 Data Integrity** — `JobPipeline.Normalize` canonicalizes status on write but preserves
unknown custom values (no silent data loss).
- **A09 Logging** — No secrets or PII added to logs by the new code.
---
## 4. Database backup — path handling
`SqliteDatabaseBackupRunner` runs `VACUUM INTO '<target>'`. The target is
`<Data:Root>/backups/jobtracker_backup_<UTC-timestamp>.db` — no user input reaches it — and single
quotes are escaped defensively. Backups contain the full database (sensitive) and are written to the
same data volume as the live DB, i.e. the same trust boundary; they are git-ignored. For defense in
depth, operators should ship backups off-host with transport encryption and restrict volume
permissions. **Recommendation (low):** document an off-host, encrypted backup rotation in the
deployment guide.
---
## 5. Low / hardening findings
1. **match-score input size (low).** `GetMatchScore` does not cap job-description length before
tokenizing. Descriptions are bounded in practice (imported/typed), and the algorithm is linear, so
this is not a DoS, but a defensive cap (e.g. 50 KB) would be prudent.
2. **New read endpoints are not rate-limited (low).** `match-score`/`status-suggestion` are cheap and
deterministic (no AI, one indexed query), and auth-gated in production, so abuse potential is low.
Consider a general authenticated-read limiter if the API is exposed publicly.
3. **status-suggestion is conservative for custom statuses (informational).** A job in a non-canonical
custom status (pipeline order = max) never receives a suggestion. This is safe (fails closed) but
slightly under-surfaces; acceptable given custom statuses are rare.
---
## 6. Secrets hygiene (actioned this session)
- Committed ASP.NET **DataProtection key XML files** (`keys/`, `JobTrackerApi/keys/`) and daily export
JSON were removed from tracking and added to `.gitignore`
(commit `security: untrack DataProtection keys and runtime exports…`).
- **These key files remain in git history.** DataProtection keys sign auth/session artifacts, so
**rotating them on the production host is recommended** (generate fresh keys; the app regenerates the
key ring in the persisted `keys/` directory on next start). Until rotated, anyone with history access
could read the old key material.
- Local `.env` remains git-ignored; `appsettings.Development.json` contains only `CHANGE_ME_*`
placeholders. No live secrets are tracked.
---
## 7. Confirmed intact from prior assessments
Spot-checked that the M013M015 remediations are still in force after this session's changes:
- Owner query filters still deny on null `CurrentUserId` (`Data/JobTrackerContext.cs`).
- Local JWT still requires a concrete subject claim (`LocalAuthIdentity`, `Program.cs`).
- Job-import SSRF guard (DNS resolution + private-range rejection, redirects disabled) untouched.
- CSRF double-submit middleware and CORS allowlist untouched.
---
## 8. Retest
All backend tests pass (135), including the two new tenant-isolation tests for the new endpoints.
No fix in this report required code changes beyond what already landed; the residual **action for the
operator is DataProtection key rotation** (§6).
+293
View File
@@ -0,0 +1,293 @@
# Jobbjakt (Job Tracker) — System Overview
> Phase 1 deliverable: full-system map produced before any code changes.
> Last updated: 2026-07-02. Verified against commit `eea327e1` plus local working-tree changes.
---
## 1. What the product is
Jobbjakt is a self-hosted, multi-user job application tracking platform with heavy AI assistance:
- Track job applications end-to-end (status pipeline, follow-ups, deadlines, salary, tags, notes).
- Company/recruiter CRM (pipeline stage, contact dates, recruiter details).
- Correspondence log per application, including **Gmail OAuth import with review workflow**.
- Attachments per application with purpose metadata and AI-inclusion toggles.
- **CV platform**: upload → OCR/text extraction → structured CV parsing (Ollama-assisted block classification) → per-job tailored CV drafts → templated PDF export via Playwright.
- AI drafts: cover letters, recruiter messages, follow-up drafts, job description summaries, translation (LibreTranslate optional).
- Rules engine (auto-ghosting, follow-up "needs attention"), reminder emails, daily JSON export, history/event trail, encrypted backup (Windows/DPAPI).
- Admin surface: user management, audit log, system readiness page.
- Deployed to production at `https://jobs.cesnimda.uk` via Gitea Actions → SSH → Docker Compose.
---
## 2. Architecture overview
```mermaid
flowchart LR
subgraph Client
UI[React 19 SPA<br/>MUI 7, react-router 6<br/>CRA/react-scripts]
end
subgraph Frontend container
NGINX[nginx 1.29-alpine<br/>serves build + proxies /api]
end
subgraph Backend container
API[ASP.NET Core net9.0 API<br/>JobTrackerApi host]
BG[Hosted services:<br/>Rules, FollowUpReminder,<br/>DailyExport, JobEnrichment,<br/>SummarizerProbe, CvProcessing]
DB[(SQLite default<br/>or MariaDB/MySQL)]
FS[/Data root:<br/>Attachments, CvArtifacts,<br/>exports, DP keys/]
end
subgraph AI stack
AISVC[FastAPI ai-service :8001<br/>distilbart summarizer,<br/>OCR pytesseract/PyMuPDF,<br/>docx/pdf extraction]
OLLAMA[Ollama :11434<br/>qwen2.5:7b<br/>CV classification + rewrite]
end
EXT1[Google OAuth / Gmail API]
EXT2[Job sites: Finn, NAV,<br/>LinkedIn, Jobbnorge]
EXT3[SMTP - Gmail app password]
EXT4[LibreTranslate optional]
UI --> NGINX --> API
API --> DB
API --> FS
API --> AISVC --> OLLAMA
API --> EXT1
API --> EXT2
API --> EXT3
API --> EXT4
BG --> DB
```
### Solution layout (unusual — read this first)
| Project | Role |
|---|---|
| `JobTrackerApi/` | Web **host** only: `Program.cs`, appsettings, migrations, Dockerfile. Its csproj **excludes** `Controllers/**` and `Services/**` from its own compilation. |
| `JobTrackerBackend/` | "Transitional shared-backend" **library** that compiles, via `<Compile Include>` links, the files physically located in `../Data`, `../Models`, `../JobTrackerApi/Controllers`, `../JobTrackerApi/Services`. Exists so tests can reference controllers/services without the web-entry project. |
| `JobTrackerApi.Tests/` | xUnit test project (~20 test classes incl. authorization/hostile-fixture tests). |
| `Models/`, `Data/` (repo root) | The *real* EF models and `JobTrackerContext`, compiled into JobTrackerBackend. |
| `Controller/` (repo root) | **Legacy stub controllers (~1 KB each) — dead code**, not referenced by any csproj. |
| `job-tracker-ui/` | React SPA. |
| `tools/summarizer/` | FastAPI AI service (own Dockerfile, pytest tests). |
| `deploy/`, `.gitea/workflows/` | Prod deploy script + CI/CD pipeline. |
| `docs/` | Session handoffs, security assessments (M013M015), UAT notes. |
---
## 3. Technology stack
**Backend**: ASP.NET Core net9.0, EF Core 9 (SQLite default; Pomelo MySQL/MariaDB switchable via `Database:Provider`), ASP.NET Identity Core (users/roles), JWT bearer auth (local + Google policy scheme), built-in RateLimiter, DataProtection (file-system keys), Playwright (CV PDF export).
**Frontend**: React 19, TypeScript 4.9, MUI 7 (+ x-data-grid, x-date-pickers, lab), axios, react-router-dom 6, @tanstack/react-table, CRA `react-scripts` 5 (build needs `--max-old-space-size=4096`), i18n EN + NB (custom provider), Jest/RTL tests.
**AI**: FastAPI + transformers (`sshleifer/distilbart-cnn-12-6`) for summaries; pytesseract/PyMuPDF/pypdf/python-docx for extraction/OCR; Ollama (`qwen2.5:7b`) for CV block classification and rewrite paths; TTL cache.
**Infra**: Docker Compose (4 services: backend, frontend/nginx, ai-service, ollama w/ GPU), Gitea Actions CI (build + backend tests + selected frontend tests + frontend build) → SSH deploy → `deploy/deploy.sh` on the prod host, external `jobtracker_shared` network.
---
## 4. Authentication & authorization
- **Smart policy scheme**: inspects the bearer token issuer — Google-issued ID tokens (`accounts.google.com`) route to the `google` JWT handler (validated against `Auth:GoogleClientId`); everything else routes to `local` JWT (symmetric key `Auth:JwtKey`, issuer/audience validated, 2-min clock skew).
- **Cookie session support**: local handler also reads the session cookie (`AuthSessionOptions.SessionCookieName`); **CSRF double-submit** middleware enforces cookie+header match for all mutating requests when a session cookie is present (login/register/reset/csrf endpoints exempt).
- `Auth:Require=true` sets a fallback authorize-all policy (prod compose sets it). Dev without a JWT key generates an ephemeral key + warning; **fails closed** if auth required but no key.
- Local tokens **must** carry a subject claim (`LocalAuthIdentity`), enforced in `OnTokenValidated` — hardened after finding M013-2.
- **Multi-tenancy**: every tenant entity carries `OwnerUserId`; `JobTrackerContext` applies global query filters `CurrentUserId != null && OwnerUserId == CurrentUserId` (deny-on-null). Correspondence/JobEvents/CV entities filter through their parent's owner.
- Roles via ASP.NET Identity: admin-only controllers (`UsersController`, `AdminAuditController`, `AdminSystemController`).
- Password policy: min 8, digit + lowercase required. Password reset via emailed token (SMTP required). Registration disabled by default.
- Rate limiting: `auth-login` (10/5 min/IP) and `auth-email` (5/15 min/IP) fixed-window policies.
---
## 5. Database schema (EF Core, 8 migrations)
```mermaid
erDiagram
ApplicationUser ||--o{ Company : owns
ApplicationUser ||--o{ JobApplication : owns
ApplicationUser ||--o| UserRuleSettings : has
ApplicationUser ||--o{ GmailConnection : has
ApplicationUser ||--o{ CvUploadArtifact : owns
ApplicationUser ||--o{ CvExtractionRun : owns
Company ||--o{ JobApplication : "has jobs"
JobApplication ||--o{ Correspondence : messages
JobApplication ||--o{ Attachment : attachments
JobApplication ||--o{ JobEvent : events
JobApplication ||--o| TailoredCvDraft : "1:1 draft"
CvUploadArtifact ||--o{ CvExtractionRun : "source of"
ApplicationUser ||--o{ GmailReviewDecision : decides
```
Key notes:
- `ApplicationUser` (IdentityUser) also stores profile CV text, **structured CV JSON** (`ProfileCvStructureJson`), avatar data-URL, Google link info, current CV artifact/run pointers.
- `JobApplication`: status string (default "Applied"), soft delete (`IsDeleted`/`DeletedAt`), tags as JSON string, imported description + translation, persisted `ShortSummary`, tailored CV text, reminder bookkeeping. Cascade deletes to messages/attachments/events/draft.
- `RuleSettings` (global, seeded Id=1) + per-user `UserRuleSettings`.
- `SystemEmailSettings`: DB-stored SMTP override (resolved by `EmailSettingsResolver`).
- Indexes: `OwnerUserId` on Company/JobApplication/GmailConnection; composite `(OwnerUserId, UploadedAtUtc)`, `(OwnerUserId, StartedAtUtc)`, unique `(OwnerUserId, JobApplicationId)` on draft, unique `(OwnerUserId, GmailAddress)`.
- SQLite file lives at `DataRoot/jobtracker.db` (WAL mode); migrations applied automatically at startup (`StartupInitializationExtensions`, 62 KB — also seeds admin, creates Identity tables where `dotnet ef` unavailable, ignores `PendingModelChangesWarning`).
---
## 6. API surface (all under `/api`, ~15 controllers)
| Controller | Highlights |
|---|---|
| `JobApplicationsController` (**151 KB!**) | CRUD, paging/filtering/sorting, board, reminders, stats, history, unified timeline, status/follow-up PATCH, soft delete/restore, **plus** AI surface: application package material, follow-up drafts, cover-letter/recruiter drafts ("Maria" drafts), workflow signals. |
| `ProfileCvController` (**117 KB**) | CV upload artifacts, extraction runs, structure parsing, rebuild/improve, tailored CV generation via Ollama rewrite, template rendering + Playwright PDF preview/export, benchmark corpus harness. |
| `GmailController` (**60 KB**) | OAuth connect/callback, sync, message review queue, import decisions, job matching. |
| `AuthController` (22 KB) | login/register/me/config, Google exchange, password reset request/reset, session cookie + CSRF endpoints. |
| `CompaniesController` | CRUD, idempotent create by name, recruiter/pipeline fields. |
| `CorrespondenceController` | per-job messages CRUD. |
| `AttachmentsController` | multipart upload to disk, download, rename, delete, purpose/AI-inclusion metadata. |
| `RulesController` | global + per-user rule settings, clamped. |
| `ExportController` | JSON/CSV export. |
| `BackupController` | DPAPI-encrypted backup (Windows only). |
| `JobImportController` | URL preview via plugin parsers (SSRF-hardened). |
| `UsersController`, `AdminAuditController`, `AdminSystemController` | admin: user/role management, audit trail, system readiness (DB/Gmail/AI). |
| `ClientErrorsController` | frontend error intake → logs. |
No OpenAPI/Swagger is wired up; the README is the de-facto API doc (already drifting).
---
## 7. Background services (6 hosted services)
| Service | Function |
|---|---|
| `RulesHostedService``RulesEngine` | periodic auto-transitions (e.g., → Ghosted) from rule settings |
| `FollowUpReminderHostedService` | reminder emails for due/upcoming follow-ups (dedup via `LastReminderEmailSentAt`) |
| `DailyExportHostedService` | daily JSON export at configured local hour |
| `JobEnrichmentHostedService` | backfills summaries/enrichment for jobs |
| `SummarizerProbeHostedService` | probes AI service readiness |
| `CvProcessingHostedService` + `CvProcessingQueue` | in-memory queue for CV extraction/processing jobs |
All state is in-process (`IMemoryCache`, in-memory queue) — single-instance assumption; no distributed locks; queue contents lost on restart.
---
## 8. AI pipeline (data flow)
1. **Job import**: URL → plugin parse (Finn/NAV/LinkedIn/Jobbnorge or universal JSON-LD parser) → optional LibreTranslate → language detect + skill tagging → preview → user accepts → stored on `JobApplication`.
2. **Summaries**: API → `SummarizerService` (31 KB) → FastAPI `/summarize` (distilbart, TTL-cached, GPU-if-available) → persisted `ShortSummary`.
3. **CV ingest**: upload (PDF/DOCX/image ≤ 8 MB) → FastAPI extract/OCR → block classification (Ollama-assisted, `CvAiClassifier`/`CvAiNormalizer`) → `ProfileCvStructureJson` on user.
4. **Tailoring**: job description + structured CV sections → Ollama rewrite path (recent commits: clamped lengths, hardened diagnostics) → `TailoredCvDraft` (JSON blocks) → `CvTemplateRenderer` (25 KB, template carousel) → Playwright → PDF.
5. **Drafts**: cover letter / recruiter message / follow-up drafts generated per job with attachment-aware context selection.
Degradation: if AI service or Ollama is down, core tracking still works (probe service + "AI is not a deploy gate" in CI).
---
## 9. Email
- `SmtpEmailSender` with `EmailSettingsResolver`: config from env/appsettings **or** DB-stored `SystemEmailSettings` (admin-editable).
- Uses Gmail SMTP + app password in prod. Flows: password reset, follow-up reminders. `App:PublicBaseUrl` builds links.
---
## 10. Configuration & secrets
- `.env` (git-ignored) → docker-compose env → ASP.NET config. `.env.example` documents the shape. Real secrets currently present in local `.env` (JWT key, admin password, SMTP app password, Google client secret).
- `appsettings.Development.json` contains only `CHANGE_ME_*` placeholders (good).
- Key knobs: `Database:Provider`, `ConnectionStrings:JobTracker`, `Data:Root`, `Cors:Origins`, `Ai:BaseUrl`, `Auth:*`, `Email:*`, `Exports:*`, `Translation:*`, `App:PublicBaseUrl`, `HttpsRedirection:*` (TLS terminated at reverse proxy; HSTS/redirect off in-container).
- `ProductionConfigTests.cs` exists to guard prod config shape.
---
## 11. Build, CI/CD, deployment
- **CI** (`.gitea/workflows/ci-deploy.yml`): on PR + push-to-main → build backend (Release), run backend tests, `npm ci`, run an **explicit whitelist of 10 frontend test files** (not the whole suite), build frontend.
- **Deploy** (push to main only): SSH to prod host → `git reset --hard <sha>` in `/opt/job-tracker/app``deploy/deploy.sh` (docker compose build/up with retry/cache-prune fallbacks) → verify containers; AI service health is non-blocking.
- Frontend Dockerfile: node build stage → nginx 1.29-alpine (working-tree bump from 1.27 pending commit); nginx proxies `/api` to backend.
- No staging environment; deploys go straight to prod after CI.
---
## 12. Testing strategy
- **Backend**: xUnit integration-style tests via `TestHostFactory`; notable coverage: authorization (`JobApplicationsAuthorizationTests`, `OwnershipGuardTests`, hostile fixture DB project), auth/system, Gmail, CV corpus harness, summarizer, SQLite migration helper, production config.
- **Frontend**: ~20 Jest/RTL test files (workspace flows, Gmail review, login, admin, attachments, drafts, trust-loop e2e-ish component tests). CI runs only the whitelisted subset.
- **AI service**: pytest (`tools/summarizer/tests/test_app.py`).
- No true end-to-end browser tests; no load/perf tests.
---
## 13. Logging & error handling
- Console/debug logging; custom middleware logs every request (method, path, status, ms, traceId, sub claim). Unhandled exceptions logged then rethrown (500).
- Client errors POSTed to `/api/client-errors` and logged server-side; React `ErrorBoundary` + route error page in UI.
- No structured sink (Seq/OTLP), no log rotation policy in-app (container stdout), no correlation to frontend errorIds beyond log text, no ProblemDetails standardization.
---
## 14. Security posture (current)
Strong points (much already hardened via M013M015 adversarial assessments in `docs/security-assessments/`):
- SSRF on job import **fixed & retested** (DNS resolution check, private/loopback/link-local rejection, redirects disabled).
- Subjectless-JWT / owner-filter bypass **fixed & retested** (fail-closed identity, deny-on-null query filters).
- Cross-user job history leak fixed (`81196374`); authorization replay findings recorded (M015).
- CSRF double-submit for cookie sessions; CORS allowlist; rate-limited login/email endpoints; ephemeral JWT key refused when auth required; Identity password hashing (PBKDF2); DataProtection keys persisted outside repo runtime path.
Open questions / watch areas (to verify in Phase 6):
- `AllowCredentials()` combined with configurable `Cors:Origins="*"` wildcard mode (SetIsOriginAllowed(true) + credentials) — dangerous if ever enabled.
- Attachment upload: file-type/size limits, path handling, content-type on download need re-audit.
- Avatar stored as data-URL on user record (size/XSS considerations).
- Gmail OAuth token storage encryption at rest; scopes; audit of `GmailController` (60 KB).
- Global rate limiting only on 2 auth policies — AI/expensive endpoints unthrottled.
- Backup endpoint Windows-only DPAPI — silently unavailable on Linux prod.
- Dependency freshness (axios, react-scripts 5/CRA is deprecated upstream; transformers/torch pinning).
- Secrets present in local `.env` (expected, git-ignored) — confirm no history leaks.
---
## 15. Technical debt report
1. **God controllers**: `JobApplicationsController` (151 KB), `ProfileCvController` (117 KB), `GmailController` (60 KB), `StartupInitializationExtensions` (62 KB). Massive single files mixing HTTP, business logic, AI prompt construction, and persistence. Highest-leverage refactor target — but high risk, needs test cover first.
2. **Transitional project layout**: `JobTrackerBackend` compiles files it doesn't own via glob includes; root `Models/`/`Data/` folders; **dead** root `Controller/` folder; `JobTrackerBackend/bin`+`obj` artifacts and `JobTrackerApi/jobtracker.db` + `bin_build/`, `CvArtifacts/`, `exports/`, `keys/` polluting the repo/working tree. `.gitignore` needs review.
3. **CI runs a hand-maintained subset** of frontend tests — new test files silently not run (already bit them once; `profile-page.test.tsx` had to be added manually).
4. **CRA/react-scripts 5** is EOL-ish, slow builds (needs 4 GB heap), TS 4.9. Vite migration is the obvious path (medium effort).
5. **Naming drift**: `Summarizer*` vs `AiService*`; "Jobbjakt" vs "Job Tracker" branding split; EN/NB translation consistency flagged in handoff doc.
6. No OpenAPI; README endpoint list already drifts from code (e.g., Gmail/profile/admin endpoints missing there).
7. In-memory queue/cache single-instance coupling undocumented.
8. Root-level clutter: `temp_job.json`, `temp_post_job.py`, `todo jobtracker.txt`, `test/`, `tmp/`, `vendor/`, `.venv/`.
9. `DaysSince` compares `DateTime.UtcNow` with `.Days` truncation — timezone/UX edge cases; status is a free string, no canonical pipeline enum (README itself lists this as a wanted improvement).
10. Windows-only backup path.
---
## 16. Areas of concern
- **Single point of data**: SQLite in a Docker volume; backups are manual/Windows-only; no automated off-host backup.
- **Deploy risk**: `git reset --hard` + straight-to-prod with no staging and non-exhaustive CI test coverage.
- **AI coupling**: prompt logic buried in controllers makes model/provider changes and testing hard.
- **Restart data loss**: queued CV processing jobs are lost on restart (in-memory queue).
- **Uncommitted working tree**: 3 modified files (Dockerfile nginx bump, `useViewResource` stale-closure fix, handoff doc) + untracked `scripts/start-ollama-cv.ps1` and a stray `JobTrackerApi/CvArtifacts/` data folder.
---
## 17. Opportunities for improvement (input to Phase 2/3)
Product (initial hypotheses, to be validated by market research):
- Canonical pipeline model + customizable Kanban stages (already on README wish list).
- Interview scheduling/prep hub (calendar integration, prep notes, question banks).
- Salary/offer comparison and analytics dashboards (funnel conversion, response rates, time-in-stage).
- Browser extension / bookmarklet for one-click job capture (plugins already exist server-side).
- Saved searches/views, full-text search, date-range and tag filters.
- Notifications beyond email (web push, digest).
- Contact-level recruiter CRM (people, not just companies).
- Mobile-friendly PWA pass.
Engineering:
- Swagger/OpenAPI + generated TS client; ProblemDetails everywhere.
- Split god controllers into feature services; move AI prompting behind interfaces.
- Run full frontend test suite in CI (`npm test -- --watchAll=false` without whitelist) once flaky tests are addressed; add `dotnet format`/eslint gates.
- Vite migration; dependency refresh.
- Durable job queue (DB-backed) for CV processing; automated DB backup job.
- Repo hygiene: delete dead `Controller/`, ignore build artifacts, remove committed DB files.

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