b176a44627
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>
376 lines
30 KiB
Markdown
376 lines
30 KiB
Markdown
# 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 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**.
|
||
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.
|