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>
| `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. |
**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.
**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.
> **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.
`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`.
- **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.
-`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`)
| `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.
**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.
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.
- 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{}`).
- 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 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 |
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.
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 600–1400-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**.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.