Files
jobtrackingapp/docs/architecture/current.md
T
cesnimda b176a44627 docs: reorganize tree, restore architecture + research from archive, add Phase 0 reports
Active docs/ was stub scaffolding while the real docs sat in docs/_archive/.
Restore and correct them, and record the Phase 0 work.

- docs/architecture/current.md: verified system map (from archived SYSTEM_OVERVIEW,
  9 corrections against code).
- docs/research/competitors.md: sourced competitor analysis (from archived
  PRODUCT_RESEARCH, feature matrix corrected).
- docs/decisions/ADR-002-job-application-model.md: the Job/JobApplication split.
- docs/application-discovery-report.md, docs/implementation-roadmap.md,
  docs/phase-0-foundation-report.md, docs/career-workspace-branch-assessment.md.
- Remove 10 zero-byte placeholder files that advertised content that never existed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:04:32 +02:00

376 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```mermaid
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), `ProfilePage.tsx` (1368), `JobTable.tsx` (786), `Correspondence.tsx` (732), `DashboardView.tsx` (666), `AdminSystemPage.tsx` (623), `AddJobModal.tsx` (618).
`ProfilePage.tsx` serves **both** `/profile` and `/career`, forked by a `careerOnly` boolean — two nav destinations rendering one component.
---
## 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 default** — `AuthController.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.
```mermaid
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 `JobApplication`** — `Job` 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 |
|---|---|
| `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 a configured local hour |
| `JobEnrichmentHostedService` | Backfills summaries/enrichment |
| `SummarizerProbeHostedService` | Probes AI service readiness |
| `CvProcessingHostedService` + `CvProcessingQueue` | In-memory queue for CV extraction |
| `DatabaseBackupHostedService``DatabaseBackupRunner` | 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`) → `StructuredCvProfile``ProfileCvStructureJson` on the user.
4. **Tailoring:** job description + structured CV → `/cv/rewrite``TailoredCvDraft` (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 hardcoded**`CvTemplateRenderer.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/app``deploy/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 `Applied`** — `JobPipeline` 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 controllers**`JobApplicationsController` 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.