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>
33 KiB
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.ukvia 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 in519c32e.
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.
- 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. - react-router-dom v6 — does the actual routing, in two different patterns inside one file (
src/App.tsx):createBrowserRouterfor public routes (/,/login,/forgot-password,/reset-password,/verify-email) and a nested<Routes>inside a catch-allShellfor authenticated routes. - 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
/registerroute exists. Sign-up is folded intoLoginPage.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.tsx → views/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 theCareerProfileServicedual-write. Called from/career.JobApplicationsController—/careerreads job list for tailoring context only.
Future extension points
CareerProfilePageis 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 (perdocs/MASTER_IMPLEMENTATION_GUIDE.md).- Source-of-truth flip (F5): today
ProfileCvStructureJsonis authoritative andCareerProfileServicemirrors it. A later phase makesCareerProfiles/CareerProfileVersionsauthoritative; the/careersave would then route throughCareerProfileServicerather than the blob column. The partial-update endpoint and the ownership split above do not change. - Full decomposition (roadmap 2.2 residue):
CareerProfilePageis 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) →googlehandler (validated againstAuth:GoogleClientId); everything else →localJWT (symmetricAuth: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=truesets 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 inOnTokenValidated— hardened after finding M013-2. - Multi-tenancy: every tenant entity carries
OwnerUserId;JobTrackerContextapplies global query filtersCurrentUserId != null && OwnerUserId == CurrentUserId(deny-on-null). Correspondence/JobEvent/CV entities filter through their parent's owner. Covered byJobApplicationsAuthorizationTests,OwnershipGuardTests. - Roles via Identity: admin-only
UsersController,AdminAuditController,AdminSystemController. - 2FA: TOTP (
Otp.NET), encrypted secrets, QR enrolment (QRCoder), recovery codes, trusted devices (jobtracker_tdcookie), pending-token flow. - Sessions:
UserSessionentity +SessionsController— list/revoke active sessions. - Password policy: min 8, digit + lowercase. Reset via emailed token (SMTP required).
- Registration is disabled by default —
AuthController.cs:135readsAuth:AllowRegistrationdefaulting tofalseand returns HTTP 403. There is no CAPTCHA anywhere. - Rate limiting (3 fixed-window policies):
auth-login10/window,auth-email5/window,auth-2fa-challenge5/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 storesProfileCvText,ProfileCvStructureJson(the master career profile — a JSON blob, not relational),AvatarImageDataUrl(base64 in a column, on the/auth/mehot path), Google/Microsoft link info, TOTP secrets, current CV artifact/run pointers.JobvsJobApplication—Jobis the opportunity (title, company, description, URL, salary, location, deadline, tags);JobApplicationis the user's pursuit of it (status, dates, follow-ups, correspondence, attachments). Introduced in Phase 0 as an additive step:JobApplication.JobIdis a nullable FK andJobApplicationstill carries its original opportunity columns for backwards compatibility. See §16 anddocs/decisions/ADR-002-job-application-model.md.- Salary is structured:
SalaryMin,SalaryMax,SalaryCurrency,SalaryPeriod(plus a legacy free-textSalary). Tagsis a JSON array in a string column — not queryable;/tagsand/tag-trendsmust scan.- Denormalized
HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachmentflags duplicateAttachments; kept honest byAttachmentFlagsRecomputeTests. - CV text is stored three times (
CvExtractionRun.RawExtractedText,.NormalizedText,.StructuredProfileJson) plus twice on the user. Deliberate audit trail, but no retention policy. Statusis free-text at the DB level; canonicalized only in the application layer byJobPipeline.Normalize— deliberately, so custom user values are never destroyed.- Indexes:
OwnerUserIdon 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 byStartupInitializationExtensions(1356 lines — also seeds admin, creates Identity tables wheredotnet efis unavailable, ignoresPendingModelChangesWarning).
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 incrementaldotnet ef migrations addworks normally. Notedotnet efstill needs theDesignpackage temporarily added toJobTrackerApi(theMigrationsAssembly), since it lives inJobTrackerBackendwithPrivateAssets=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.mdis 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:
- Job import: URL → plugin parse (Finn/NAV/LinkedIn/Jobbnorge) or universal JSON-LD parser → optional LibreTranslate → language detect + skill tagging → preview → user accepts → persisted.
- Summaries:
SummarizerService→/summarize(distilbart, TTL-cached, GPU if available) → persistedShortSummary. - CV ingest: upload (PDF/DOCX/image) →
/extract-text(OCR) → block classification (CvAiClassifier/CvAiNormalizervia/cv/classify-block) →StructuredCvProfile→ProfileCvStructureJsonon the user. - Tailoring: job description + structured CV →
/cv/rewrite→TailoredCvDraft(separate entity, per application) →CvTemplateRenderer→ Playwright → PDF. - 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.exampledocuments the shape.appsettings.Development.jsonholds onlyCHANGE_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_TOKENis mandatory. BothAi__ServiceToken(backend) andAI_SERVICE_TOKEN(ai-service) use${AI_SERVICE_TOKEN:?...}, sodocker compose upfails loudly rather than booting an unauthenticated AI service. Generate withpython -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.csguards prod config shape.- Ollama is intentionally not bundled by default (
bundled-ollamacompose profile) so deploys reuse a shared instance.AI_PROVIDER=geminiexists 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 --runInBandand 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 ciSIGSEGV 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 atools/hostile-fixture-dbproject. - 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.
.envgit-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):
- No host port.
ports: "8001:8001"removed;expose:only. - Private network.
ai-servicesits on a newai_internalbridge and nothing else. It was removed fromdefault(which the frontend shares) and fromshared_services— the latter isexternal: true(jobtracker_shared), so any other compose stack on the host could join it and reach port 8001.ai_internalhas exactly two members:ai-serviceandbackend. It is notinternal: true, because ai-service needs egress to Gemini/Groq. - Shared secret.
X-Ai-Service-Tokenrequired on every endpoint except/health, compared withhmac.compare_digest. Backend sends it viaAi:ServiceToken; sidecar readsAI_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_URLat an Ollama in another compose stack, address it by host IP (e.g.http://<host-ip>:11435) —ai-servicecan no longer resolve container names onshared_services, by design. The bundledollamaprofile is onai_internaland still works by name. - No host port.
-
Pipeline expanded beyond
Applied—JobPipelinenow models pre-application stages (Saved,Interested,Preparing) in a newPipelineCategory.Prospect, so a job can be tracked before it is applied to.Savedis the new default for wizard-created jobs;Appliedremains the default for the legacy create path. -
DateAppliedis nullable +SavedAtadded — a saved job no longer carries a fabricated application date. -
Jobentity introduced alongsideJobApplication(additive;JobApplication.JobIdnullable FK). No behaviour moved yet — this only makes the split possible.
17. Known debt (ranked)
JobApplicationstill carries opportunity columns — theJobsplit is started but not completed. Reads/writes still use the legacy columns.- God controllers —
JobApplicationsController2313/38 endpoints,ProfileCvController2249,StartupInitializationExtensions1356,GmailController1023,AuthController879. - Hardcoded CV templates — dead end for the CV Builder.
- Three frontend toolchains — Next shell + react-router (×2 patterns) + react-scripts test runner.
- No frontend data layer — root cause of the 600–1400-line components.
ProfilePage(1368 lines) serves two routes behind a boolean; thecareerViewprop it accepts is never read (the "CV Builder" tab is inert).- Denormalized
Has*flags;Tagsas a JSON string column; unbounded CV storage; base64 avatars in a DB column. JobTrackerBackendlink-compilation — self-described "transitional".- In-memory queue/cache — single-instance coupling; restart loses queued CV jobs.
- No OpenAPI in prod, no ProblemDetails, no structured logging sink.
- 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. - 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:
JobTrackerBackendlink-compilation exists so tests can reach controllers without the web host — deliberate, self-described "transitional".Statusis free-text and canonicalized in the application layer specifically so custom user values are never destroyed (JobPipeline.csdocstring). Deliberate; do not "fix" it with a DB enum.- The CI frontend-test whitelist was removed after it "silently skipped new suites and let two regressions reach main."
- Ollama is intentionally not bundled by default so deploys reuse a shared instance.
AI_PROVIDER=geminiexists to offload a weak local GPU in prod.TailoredCvDraftis a separate entity specifically to guarantee the master CV is never auto-modified.