Files
jobtrackingapp/docs/architecture/current.md
T
cesnimda cf8b2fa014
CI and Deploy / test (push) Failing after 1m50s
CI and Deploy / deploy (push) Has been skipped
docs(architecture): document profile and career ownership
Add section 4a to docs/architecture/current.md: request flow, data ownership,
API responsibilities, and future extension points for the /profile vs /career
separation completed in Phase 2/2.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:13:06 +02:00

33 KiB
Raw Blame History

Jobjakt — Current Architecture

This document describes the system as it actually is. Every claim was verified against code. Last verified: 2026-07-17 (Phase 0). Supersedes the archived docs/_archive/SYSTEM_OVERVIEW.md (2026-07-02).

Rule: if this document and the code disagree, the code wins — and this document is a bug. Fix it. Do not trust other files under docs/ over this one; most are stubs.


1. What the product is

Jobjakt is a self-hosted, multi-user job application tracking platform with local-AI career assistance:

  • Track jobs and applications end-to-end (pipeline stages, follow-ups, deadlines, salary, tags, notes).
  • Company CRM (pipeline stage, contact dates, recruiter details) — company-level only, no people entities.
  • Correspondence log per application: Gmail OAuth import with a human review queue, IMAP, Microsoft Graph.
  • Attachments per application with purpose metadata and AI-inclusion toggles.
  • CV platform: upload → OCR/extraction → structured parsing → per-job tailored CV drafts → templated PDF via Playwright.
  • AI drafts: cover letters, recruiter messages, follow-up drafts, job summaries, match scoring, interview prep.
  • Rules engine (auto-ghosting), reminder emails, daily JSON export, event trail, automated DB backup.
  • Admin: user management, audit log, system readiness.
  • Production: https://jobs.cesnimda.uk via Gitea Actions → SSH → Docker Compose.

Product hierarchy (from docs/MASTER_IMPLEMENTATION_GUIDE.md — job tracking is the core; career tools support it): Job Tracking → Applications → Workflow → Follow-ups → Communication, then Career Profile → Master CV → CV Builder → Cover Letters → Portfolio → Interview Prep, then Job Discovery.


2. Architecture overview

flowchart LR
    subgraph Client
        UI[React 19 SPA<br/>MUI 7, react-router 6<br/>Next.js 16 CSR shell]
    end

    subgraph Frontend container
        NGINX[nginx 1.29-alpine<br/>serves static export + proxies /api]
    end

    subgraph Backend container
        API[ASP.NET Core net9.0<br/>JobTrackerApi host]
        BG[7 hosted services:<br/>Rules, FollowUpReminder, DailyExport,<br/>JobEnrichment, SummarizerProbe,<br/>CvProcessing, DatabaseBackup]
        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, docx/pdf extraction]
        PROV[Provider via AI_PROVIDER env:<br/>ollama qwen2.5:7b / gemini / groq]
    end

    EXT1[Google OAuth / Gmail API]
    EXT2[Microsoft Graph / IMAP]
    EXT3[Job sites: Finn, NAV,<br/>LinkedIn, Jobbnorge]
    EXT4[SMTP]
    EXT5[LibreTranslate optional]

    UI --> NGINX --> API
    API --> DB
    API --> FS
    API --> AISVC --> PROV
    API --> EXT1
    API --> EXT2
    API --> EXT3
    API --> EXT4
    API --> EXT5
    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 link-compiles, via <Compile Include>, files physically located in ../Data, ../Models, ../JobTrackerApi/Controllers, ../JobTrackerApi/Services. Exists so tests can reference controllers/services without the web host.
JobTrackerApi.Tests/ xUnit, 36 test files incl. authorization + hostile-fixture suites.
Models/, Data/ (repo root) The real EF models and JobTrackerContext.
job-tracker-ui/ React SPA inside a Next.js shell.
tools/summarizer/ FastAPI AI service (own Dockerfile, pytest tests).
tools/hostile-fixture-db/ Security test fixture generator.
deploy/, .gitea/workflows/ Prod deploy script + CI/CD.

Source lives in one place and compiles from another. Any tool assuming csproj-adjacent source will mislead you. Check JobTrackerBackend.csproj before adding files or projects.

Corrected 2026-07-17: the dead root Controller/ (singular) folder described in the archived overview no longer exists — removed in 519c32e.


3. Technology stack

Backend: ASP.NET Core net9.0, EF Core 9 (SQLite default; Pomelo MySQL/MariaDB via Database:Provider), ASP.NET Identity Core, JWT bearer (smart policy scheme: local + Google), built-in RateLimiter, DataProtection (file-system keys), Playwright (PDF export).

Frontend: Next.js 16 + React 19 + TypeScript 5.9 + MUI 7 (+ x-data-grid, x-date-pickers, lab), axios, react-router-dom 6, @tanstack/react-table, i18n EN + NB (custom provider), Jest/RTL.

Corrected 2026-07-17: the archived overview said "CRA/react-scripts 5, TypeScript 4.9". The CRA→Next.js migration has happened. See §4 for what that migration did and did not do.

AI: FastAPI + transformers (sshleifer/distilbart-cnn-12-6) for summaries; pytesseract/PyMuPDF/pypdf/python-docx for extraction/OCR; one generation provider selected by the AI_PROVIDER env var ∈ {ollama (default, qwen2.5:7b), gemini, groq}; TTL cache.

Infra: Docker Compose (backend, frontend/nginx 1.29-alpine, ai-service, ollama opt-in via bundled-ollama profile w/ GPU), Gitea Actions CI → SSH deploy → deploy/deploy.sh, external jobtracker_shared network.


4. Frontend architecture

Three toolchains coexist. This is the single most confusing thing about the frontend.

  1. Next.js 16 App Router (app/layout.tsx, app/page.tsx) — a thin shell that mounts a client-side app. The CRA→Next migration was a CSR lift-and-shift: no SSR, no server components, no Next routing, no data fetching. Next is effectively a build tool here. Static export → nginx.
  2. react-router-dom v6 — does the actual routing, in two different patterns inside one file (src/App.tsx): createBrowserRouter for public routes (/, /login, /forgot-password, /reset-password, /verify-email) and a nested <Routes> inside a catch-all Shell for authenticated routes.
  3. react-scripts 5.0.1 — still a dependency, used only as the test runner ("test": "react-scripts test").

Known consequence: a dev-only 404 on deep links follows directly from the Next shell + client router combination.

Routes (src/App.tsx): public — /, /login, /forgot-password, /reset-password, /verify-email. Authenticated — /dashboard, /jobs, /reminders, /kanban, /companies, /correspondence, /correspondence/review, /profile, /career, /trash, /settings, /settings/connected-accounts, /admin/{audit,users,system}.

No /register route exists. Sign-up is folded into LoginPage.tsx, and the endpoint is disabled by default (§5).

State management: none. No Redux/Zustand/React Query. Local useState + axios per component, with a hand-rolled refreshToken counter threaded through props. Two workspace-cache hooks exist (components/job-workspace/useWorkspaceTabCache.ts, useJobWorkspaceBaseData.ts). This is the root cause of the oversized components below.

Styling: MUI sx + custom src/theme.ts (439 lines), light/dark. Design tokens live inline in component sx props rather than in the theme (e.g. the same boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" is repeated across pages). There is no component primitives layer and no Storybook.

Oversized components (refactor targets, in order): JobDetailsDialog.tsx (1400), CareerProfilePage.tsx (1293), JobTable.tsx (786), Correspondence.tsx (732), DashboardView.tsx (666), AdminSystemPage.tsx (623), AddJobModal.tsx (618). (ProfilePage.tsx was 1368; Phase 2.2 split it — see §4a.)


4a. Profile / Career separation (Phase 2, 2026-07-17)

/profile and /career were one 1368-line component (ProfilePage) forked by a careerOnly boolean. Phase 2 scoped their saves; Phase 2.2 split them into two dedicated components. This is the reference model for how account identity and the master career profile relate.

Components & routes

Route Component Owns
/profile views/ProfilePage.tsx (~490 lines) Account identity + security + preferences
/career views/CareerWorkspacePage.tsxviews/CareerProfilePage.tsx (~1293 lines) The master career profile — the single editable source of truth

CareerWorkspacePage is a thin shell (heading + source-of-truth notice) around CareerProfilePage. The CV Builder is not built yet (Phase 4); CareerProfilePage is where it will live.

Request flow

/profile  → ProfilePage
    load:  GET  /auth/me                              (account row only)
    save:  PUT  /auth/profile { email, userName, firstName, lastName, displayName }

/career   → CareerWorkspacePage → CareerProfilePage
    load:  GET  /auth/me
           GET  /profile-cv/runs                      (extraction history)
           GET  /jobapplications?…                    (for per-job CV tailoring context)
    save:  PUT  /auth/profile { profileCvText, profileCvStructureJson }
           (ProfileCvController paths additionally dual-write CareerProfileService —
            CareerProfiles / CareerProfileVersions — see §9 / §16)

Data ownership (the invariant)

Both surfaces persist through the same endpoint, PUT /auth/profile, which does partial updates (AuthController.UpdateProfile): a field is touched only if the request carries it — null/omitted leaves it unchanged, "" clears it, a value sets it. Email and UserName are never cleared (login identifiers).

Field(s) on ApplicationUser Owner (only surface that writes them)
Email, UserName, FirstName, LastName, DisplayName, AvatarImageDataUrl, password, TOTP/2FA, linked OAuth accounts /profile
ProfileCvText, ProfileCvStructureJson (the master career profile) /career

Because the endpoint is partial, /profile saving identity does not null the master profile, and /career saving the profile does not null identity. This is enforced by tests (AuthAndSystemControllerTests: identity-save-keeps-CV, career-save-keeps-identity, empty-clears, null-leaves).

API responsibilities

  • AuthController.UpdateProfile (PUT /auth/profile) — partial update of the account row; the single write path for both surfaces. Local accounts only.
  • AuthController (GET /auth/me) — returns the whole account row; each surface reads the fields it owns.
  • ProfileCvController — CV ingest/parse/rewrite/export and the CareerProfileService dual-write. Called from /career.
  • JobApplicationsController/career reads job list for tailoring context only.

Future extension points

  • CareerProfilePage is the foundation for all future career outputs (Phase 3/4): CV Builder, tailored CVs, cover letters, portfolio, interview prep. They attach here, referencing the master profile — never duplicating it (per docs/MASTER_IMPLEMENTATION_GUIDE.md).
  • Source-of-truth flip (F5): today ProfileCvStructureJson is authoritative and CareerProfileService mirrors it. A later phase makes CareerProfiles/CareerProfileVersions authoritative; the /career save would then route through CareerProfileService rather than the blob column. The partial-update endpoint and the ownership split above do not change.
  • Full decomposition (roadmap 2.2 residue): CareerProfilePage is still large because it owns the whole master-CV surface; the CV Builder work will extract sub-components from it.

5. Authentication & authorization

  • Smart policy scheme: inspects the bearer token issuer — Google ID tokens (accounts.google.com) → google handler (validated against Auth:GoogleClientId); everything else → local JWT (symmetric Auth:JwtKey, issuer/audience validated, 2-min clock skew).
  • Cookie sessions: local handler also reads jobtracker_auth (HttpOnly, SameSite=Lax, Secure-configurable, 30d when persistent). CSRF double-submit middleware enforces cookie+header match on all mutating requests when a session cookie is present (login/register/reset/csrf 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 is required but unconfigured.
  • 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/JobEvent/CV entities filter through their parent's owner. Covered by JobApplicationsAuthorizationTests, OwnershipGuardTests.
  • Roles via Identity: admin-only UsersController, AdminAuditController, AdminSystemController.
  • 2FA: TOTP (Otp.NET), encrypted secrets, QR enrolment (QRCoder), recovery codes, trusted devices (jobtracker_td cookie), pending-token flow.
  • Sessions: UserSession entity + SessionsController — list/revoke active sessions.
  • Password policy: min 8, digit + lowercase. Reset via emailed token (SMTP required).
  • Registration is disabled by defaultAuthController.cs:135 reads Auth:AllowRegistration defaulting to false and returns HTTP 403. There is no CAPTCHA anywhere.
  • Rate limiting (3 fixed-window policies): auth-login 10/window, auth-email 5/window, auth-2fa-challenge 5/window. AI and other expensive endpoints are unthrottled.

6. Database

EF Core, 11 migrations. App DbSets + Identity tables.

erDiagram
    ApplicationUser ||--o{ Company : owns
    ApplicationUser ||--o{ Job : owns
    ApplicationUser ||--o{ JobApplication : owns
    ApplicationUser ||--o| UserRuleSettings : has
    ApplicationUser ||--o{ GmailConnection : has
    ApplicationUser ||--o{ CvUploadArtifact : owns
    ApplicationUser ||--o{ CvExtractionRun : owns
    ApplicationUser ||--o{ UserSession : has
    ApplicationUser ||--o{ TrustedDevice : has
    Company ||--o{ Job : "posts"
    Company ||--o{ JobApplication : "has jobs"
    Job ||--o{ JobApplication : "applied to via"
    JobApplication ||--o{ Correspondence : messages
    JobApplication ||--o{ Attachment : attachments
    JobApplication ||--o{ JobEvent : events
    JobApplication ||--o| TailoredCvDraft : "1:1 draft"
    CvUploadArtifact ||--o{ CvExtractionRun : "source of"

Entities: Company, Job, JobApplication, Correspondence, GmailConnection, GmailReviewDecision, MicrosoftGraphConnection, ImapConnection, Attachment, RuleSettings, UserRuleSettings, SystemEmailSettings, JobEvent, CvUploadArtifact, CvExtractionRun, TailoredCvDraft, TwoFactorRecoveryCode, TrustedDevice, UserSession.

Key notes:

  • ApplicationUser (IdentityUser) also stores ProfileCvText, ProfileCvStructureJson (the master career profile — a JSON blob, not relational), AvatarImageDataUrl (base64 in a column, on the /auth/me hot path), Google/Microsoft link info, TOTP secrets, current CV artifact/run pointers.
  • Job vs JobApplicationJob is the opportunity (title, company, description, URL, salary, location, deadline, tags); JobApplication is the user's pursuit of it (status, dates, follow-ups, correspondence, attachments). Introduced in Phase 0 as an additive step: JobApplication.JobId is a nullable FK and JobApplication still carries its original opportunity columns for backwards compatibility. See §16 and docs/decisions/ADR-002-job-application-model.md.
  • Salary is structured: SalaryMin, SalaryMax, SalaryCurrency, SalaryPeriod (plus a legacy free-text Salary).
  • Tags is a JSON array in a string column — not queryable; /tags and /tag-trends must scan.
  • Denormalized HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment flags duplicate Attachments; kept honest by AttachmentFlagsRecomputeTests.
  • CV text is stored three times (CvExtractionRun.RawExtractedText, .NormalizedText, .StructuredProfileJson) plus twice on the user. Deliberate audit trail, but no retention policy.
  • Status is free-text at the DB level; canonicalized only in the application layer by JobPipeline.Normalize — deliberately, so custom user values are never destroyed.
  • Indexes: OwnerUserId on Company/Job/JobApplication/GmailConnection; composites (OwnerUserId, UploadedAtUtc), (OwnerUserId, StartedAtUtc); unique (OwnerUserId, JobApplicationId) on draft, unique (OwnerUserId, GmailAddress). EF auto-indexes FKs by convention. Genuinely missing: owner-prefixed composites (OwnerUserId, IsDeleted, Status) and (OwnerUserId, FollowUpAt).
  • SQLite at DataRoot/jobtracker.db (WAL); migrations applied at startup by StartupInitializationExtensions (1356 lines — also seeds admin, creates Identity tables where dotnet ef is unavailable, ignores PendingModelChangesWarning).

Corrected 2026-07-17: prior session notes recorded the EF model snapshot as broken/empty. It was resynced in 20260711181039_SyncModelSnapshot — the snapshot now covers the full model and incremental dotnet ef migrations add works normally. Note dotnet ef still needs the Design package temporarily added to JobTrackerApi (the MigrationsAssembly), since it lives in JobTrackerBackend with PrivateAssets=all.


7. API surface (19 controllers, all under /api)

Controller Lines Highlights
JobApplicationsController 2313 38 endpoints. CRUD, paging/filter/sort, board, reminders, stats, analytics, history, timeline, status/follow-up PATCH, soft delete/restore, duplicate-check, plus the whole AI surface: match-score, candidate-fit, focus-plan, interview-prep, readiness, tailored-CV draft/preview/export/generate, application-drafts, application-package, follow-up drafts + send, ai-metrics.
ProfileCvController 2249 CV upload artifacts, extraction runs, structure parsing, reprocess/rebuild/improve, rewrite-section, rewrite-preview, templates, Playwright PDF export, benchmark harness.
GmailController 1023 OAuth connect/callback, sync, review queue, import decisions, job matching.
AuthController 879 login/register/me/config, Google + Microsoft exchange and link/unlink, avatar, password change/reset, email verification, session cookie + CSRF.
AdminSystemController 342 System readiness (DB/Gmail/AI).
TwoFactorController 341 TOTP enrol/verify/disable, recovery codes.
AttachmentsController 245 Multipart upload to disk, download, rename, delete, purpose/AI-inclusion metadata.
UsersController 229 Admin user/role management.
AdminAuditController 219 Audit trail.
CorrespondenceController 185 Per-job messages CRUD.
CompaniesController 150 CRUD, idempotent create-by-name, recruiter/pipeline fields.
MicrosoftGraphController 150 Outlook/M365 mail linking.
SessionsController 104 List/revoke sessions.
ExportController 102 JSON/CSV export.
RulesController 101 Global + per-user rule settings, clamped.
ClientErrorsController 100 Frontend error intake → logs.
ImapController 96 IMAP mail linking (SSRF-guarded).
BackupController 89 Manual backup trigger.
JobImportController 27 One endpoint: POST /preview. URL parse only — no persistence, no import history.

God controllers are a top debt. JobApplicationsController and ProfileCvController mix HTTP, business logic, AI prompt construction, and persistence. docs/MASTER_IMPLEMENTATION_GUIDE.md forbids exactly this ("Avoid: Massive controllers"). Refactor needs test cover first — the tests exist.

OpenAPI is wired (AddOpenApi / MapOpenApi) but dev-only — guarded by app.Environment.IsDevelopment(), not exposed in production.


8. Background services (7 hosted services)

Service Function
RulesHostedServiceRulesEngine 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 a configured local hour
JobEnrichmentHostedService Backfills summaries/enrichment
SummarizerProbeHostedService Probes AI service readiness
CvProcessingHostedService + CvProcessingQueue In-memory queue for CV extraction
DatabaseBackupHostedServiceDatabaseBackupRunner Automated DB backup (VACUUM INTO, server-derived path)

All state is in-process (IMemoryCache, in-memory queue) — single-instance assumption, no distributed locks, queued CV jobs are lost on restart.


9. AI pipeline

Architecture: the backend does not call any LLM in-process. It HTTP-calls a FastAPI sidecar (tools/summarizer/app.py) exposing /health, /cv/normalize, /cv/classify-block, /cv/rewrite, /summarize, /extract-text. The sidecar picks one provider from the process-wide AI_PROVIDER env var ∈ {ollama, gemini, groq}.

Important — docs/00-ai-context.md is wrong about this. It describes a provider interface fanning out to OpenAI/Gemini/Claude/Ollama, admin-controlled, with users never locked to one model. None of that exists. There is one env var, one provider per deployment, no OpenAI, no Claude, no admin control, no per-user selection. Product decision 2026-07-17: the docs get fixed, the abstraction does not get built — revisit only if a customer asks.

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 → persisted.
  2. Summaries: SummarizerService/summarize (distilbart, TTL-cached, GPU if available) → persisted ShortSummary.
  3. CV ingest: upload (PDF/DOCX/image) → /extract-text (OCR) → block classification (CvAiClassifier/CvAiNormalizer via /cv/classify-block) → StructuredCvProfileProfileCvStructureJson on the user.
  4. Tailoring: job description + structured CV → /cv/rewriteTailoredCvDraft (separate entity, per application) → CvTemplateRenderer → Playwright → PDF.
  5. Drafts: cover letter / recruiter message / follow-up per job, attachment-aware context selection.

Invariant that holds: the master profile is never auto-overwritten. Tailored output lands in TailoredCvDraft, a separate entity. This is the most important documented rule and it is correctly implemented — do not break it.

Degradation: if the AI service or provider is down, core tracking still works (probe service; AI is not a deploy gate).

CV templates are hardcodedCvTemplateRenderer.Render is a C# switch over 6 template IDs (ats-minimal, harvard, auckland, edinburgh, monarch, fjord), each a function interpolating HTML strings, with booleans like roundedPhoto/curvedHeader. There is no theme model and nothing is user-customisable. This is a structural dead end for the CV Builder — see docs/application-discovery-report.md §10.


10. Email

SmtpEmailSender + EmailSettingsResolver: config from env/appsettings or DB-stored SystemEmailSettings (admin-editable). Gmail SMTP + app password in prod. Flows: password reset, email verification, follow-up reminders. App:PublicBaseUrl builds links.

Inbound: GmailOAuthService (655), MicrosoftGraphOAuthService (507), ImapService (345, SSRF-guarded).


11. Configuration & secrets

  • .env (git-ignored) → docker-compose env → ASP.NET config. .env.example documents the shape.
  • appsettings.Development.json holds only CHANGE_ME_* placeholders.
  • Key knobs: Database:Provider, ConnectionStrings:JobTracker, Data:Root, Cors:Origins, Ai:BaseUrl, Summarizer:BaseUrl, Ai:ServiceToken, Auth:* (incl. Auth:AllowRegistration), Email:*, Exports:*, App:*, HttpsRedirection:* (TLS terminated at the reverse proxy).
  • AI service knobs (compose): AI_PROVIDER, AI_SERVICE_TOKEN, OLLAMA_BASE_URL, OLLAMA_MODEL, GEMINI_API_KEY, GROQ_API_KEY.
  • AI_SERVICE_TOKEN is mandatory. Both Ai__ServiceToken (backend) and AI_SERVICE_TOKEN (ai-service) use ${AI_SERVICE_TOKEN:?...}, so docker compose up fails loudly rather than booting an unauthenticated AI service. Generate with python -c "import secrets; print(secrets.token_hex(32))". Rotating it requires recreating both containers together — they must agree.
  • Note both those compose entries are quoted: the :? error message contains a colon-space, which YAML would otherwise parse as a map (services.backend.environment.[20]: unexpected type map[string]interface{}).
  • ProductionConfigTests.cs guards prod config shape.
  • Ollama is intentionally not bundled by default (bundled-ollama compose profile) so deploys reuse a shared instance. AI_PROVIDER=gemini exists specifically to offload a weak local GPU in prod.

12. Build, CI/CD, deployment

  • CI is Gitea, not GitHub.gitea/workflows/ci-deploy.yml. There is no .github/ directory.

  • On PR + push-to-main: build backend (Release) → run all backend tests → npm ci → run the whole frontend suite → build frontend.

    Corrected 2026-07-17: the archived overview said CI runs "an explicit whitelist of 10 frontend test files". The whitelist is gone. The workflow now runs npm test -- --watchAll=false --runInBand and carries a comment forbidding its return: the previous whitelist "silently skipped new suites and let two regressions reach main."

  • The workflow is heavily defended against a flaky self-hosted runner: dotnet install retry, npm ci SIGSEGV retry, frontend build OOM retry.

  • Deploy (push to main only): SSH to prod → git reset --hard <sha> in /opt/job-tracker/appdeploy/deploy.sh (compose build/up with retry + cache-prune fallbacks) → verify containers. AI health is non-blocking.

  • No staging environment. Deploys go straight to prod after CI.


13. Testing

  • Backend: xUnit integration-style via TestHostFactory. 36 test files. Notable: JobApplicationsAuthorizationTests, OwnershipGuardTests, ImapServiceSsrfGuardTests, ProductionConfigTests, AttachmentFlagsRecomputeTests, CvCorpusHarnessTests, SqliteMigrationHelperTests, JobPipelineTests, plus a tools/hostile-fixture-db project.
  • Frontend: ~20 Jest/RTL files — all run in CI.
  • AI service: pytest (tools/summarizer/tests/).
  • Gaps: no true end-to-end browser tests; no load/perf tests; no dependency CVE scanning (CI explicitly sets npm_config_audit: 'false').

14. Logging & error handling

Console/debug logging; middleware logs every request (method, path, status, ms, traceId, sub claim). Unhandled exceptions logged then rethrown (500). Client errors POST to /api/client-errors. React ErrorBoundary + route error page.

No structured sink (Seq/OTLP), no in-app log rotation, no ProblemDetails standardization.


15. Security posture

Verified strong:

  • Multi-tenancy via deny-on-null global query filters, with a dedicated authorization test suite.
  • CSRF double-submit on mutating requests; HttpOnly SameSite=Lax session cookie.
  • Auth fails closed when required but unconfigured; subjectless-JWT rejected (M013-2).
  • SSRF on job import and IMAP fixed and retested (DNS resolution check, private/loopback/link-local rejection, redirects disabled).
  • Rate-limited login/email/2FA endpoints; Identity PBKDF2 hashing.
  • OpenAPI dev-only. .env git-ignored; DP keys and runtime exports untracked (519c32e).
  • 2FA + recovery codes + trusted devices + session revocation.
  • AI sidecar: backend-only. Unpublished, on a private two-member network, and token-authenticated (§16). Verified against the running stack, not just configured.

Open findings (detail in docs/application-discovery-report.md §12 and docs/phase-0-foundation-report.md):

Sev Finding Status
Medium DataProtection keys recoverable from git history (519c32e, 955cae6) Open — rotation required, needs an operator
Medium CORS: Cors:Origins="*" triggers SetIsOriginAllowed(_ => true) + AllowCredentials() (Program.cs:96-102) — reflected-origin with cookies = session theft from any site. Not currently active (compose never sets Cors__Origins, so it defaults to localhost:3000), but it is one config value away. Open — landmine
Medium No AI cost ceiling (no quota, no metering, unthrottled) Open
Low No CAPTCHA (rate limiting only) Open — blocks public signup
Low Unbounded storage: attachments, CV artifacts, extraction runs, base64 avatars Open
Low Backup / DPAPI is Windows-oriented — verify behaviour on Linux prod Unverified
Low No dependency CVE scanning in CI Open

16. Phase 0 changes (2026-07-17)

Full record: docs/phase-0-foundation-report.md. What changed architecturally:

  • AI sidecar secured — three layers, verified against the running stack (2026-07-17):

    1. No host port. ports: "8001:8001" removed; expose: only.
    2. Private network. ai-service sits on a new ai_internal bridge and nothing else. It was removed from default (which the frontend shares) and from shared_services — the latter is external: true (jobtracker_shared), so any other compose stack on the host could join it and reach port 8001. ai_internal has exactly two members: ai-service and backend. It is not internal: true, because ai-service needs egress to Gemini/Groq.
    3. Shared secret. X-Ai-Service-Token required on every endpoint except /health, compared with hmac.compare_digest. Backend sends it via Ai:ServiceToken; sidecar reads AI_SERVICE_TOKEN. Unset = open (local dev/tests), but compose declares both with :? so the stack refuses to start without it.

    Only the backend can reach the AI service. Verified live: host → connection refused; frontend container → cannot even resolve ai-service; unauthenticated calls to /summarize, /cv/rewrite, /extract-text → 401; wrong token → 401; backend (172.23.0.3) with token → 200 OK.

    If you point OLLAMA_BASE_URL at an Ollama in another compose stack, address it by host IP (e.g. http://<host-ip>:11435) — ai-service can no longer resolve container names on shared_services, by design. The bundled ollama profile is on ai_internal and still works by name.

  • Pipeline expanded beyond AppliedJobPipeline now models pre-application stages (Saved, Interested, Preparing) in a new PipelineCategory.Prospect, so a job can be tracked before it is applied to. Saved is the new default for wizard-created jobs; Applied remains the default for the legacy create path.

  • DateApplied is nullable + SavedAt added — a saved job no longer carries a fabricated application date.

  • Job entity introduced alongside JobApplication (additive; JobApplication.JobId nullable FK). No behaviour moved yet — this only makes the split possible.


17. Known debt (ranked)

  1. JobApplication still carries opportunity columns — the Job split is started but not completed. Reads/writes still use the legacy columns.
  2. God controllersJobApplicationsController 2313/38 endpoints, ProfileCvController 2249, StartupInitializationExtensions 1356, GmailController 1023, AuthController 879.
  3. Hardcoded CV templates — dead end for the CV Builder.
  4. Three frontend toolchains — Next shell + react-router (×2 patterns) + react-scripts test runner.
  5. No frontend data layer — root cause of the 6001400-line components.
  6. ProfilePage (1368 lines) serves two routes behind a boolean; the careerView prop it accepts is never read (the "CV Builder" tab is inert).
  7. Denormalized Has* flags; Tags as a JSON string column; unbounded CV storage; base64 avatars in a DB column.
  8. JobTrackerBackend link-compilation — self-described "transitional".
  9. In-memory queue/cache — single-instance coupling; restart loses queued CV jobs.
  10. No OpenAPI in prod, no ProblemDetails, no structured logging sink.
  11. Root-level clutter: temp_job.json, temp_post_job.py, todo jobtracker.txt, test/, tmp/, vendor/, docs.7z, CV_Changes.md, SMART_GMAIL_PROGRESS.md.
  12. Norway-only import plugins (Finn/NAV/Jobbnorge). Product decision 2026-07-17: Norway first, but no hardcoding Norway — market must become a data dimension, not an assumption.

18. Historical decisions worth knowing

Recorded nowhere else in active docs:

  1. JobTrackerBackend link-compilation exists so tests can reach controllers without the web host — deliberate, self-described "transitional".
  2. Status is free-text and canonicalized in the application layer specifically so custom user values are never destroyed (JobPipeline.cs docstring). Deliberate; do not "fix" it with a DB enum.
  3. The CI frontend-test whitelist was removed after it "silently skipped new suites and let two regressions reach main."
  4. Ollama is intentionally not bundled by default so deploys reuse a shared instance.
  5. AI_PROVIDER=gemini exists to offload a weak local GPU in prod.
  6. TailoredCvDraft is a separate entity specifically to guarantee the master CV is never auto-modified.