The routed job workspace owns analysis, correspondence, timeline, and interview flows. Remove the unreachable duplicate dialog and keep coverage on the live components and route contracts.
Job Tracker
Job Tracker is a self-hosted career workspace built with a Next.js/React frontend and an ASP.NET Core API. SQLite is the local default; MariaDB/MySQL is the supported server-database option.
Features (high level)
- Track job applications (status, applied date, notes, tags, follow-up dates, deadlines, salary, links, etc.)
- Share opt-in public CV links with recruiter-friendly PDF download
- Company management (location/source, recruiter details, pipeline stage, next contact date)
- Correspondence log per application (email/messages with subject/channel/date)
- Attachments per application (upload, list, download, rename, delete)
- Reminders + follow-up “needs attention” logic driven by configurable rules
- History/event trail per application (created, status changes, follow-up set, delete/restore)
- Export jobs to JSON/CSV + daily scheduled JSON export
- Optional “job import” preview from supported job sites (plugins) + optional translation to English
- Quick-capture bookmarklet (Settings) + installable PWA with a mobile share-target: both open
/?add=<page url>to pre-fill Add Job from any posting- Note: no offline service-worker cache is bundled by design (the app is deployed frequently; an aggressive cache would risk serving stale builds). The manifest provides installability and share-to-capture without it.
- Optional local AI service for short/full descriptions
- Optional Google sign-in (Google ID tokens) to protect the API
- Standard Problem Details responses with trace IDs for unhandled API errors
Architecture
job-tracker-ui/: statically exported Next.js 16 / React 19 app (runs onhttp://localhost:3000in dev)JobTrackerApi/: ASP.NET Core API (defaults tohttp://localhost:5202)- Database: SQLite defaults to
JobTrackerApi/jobtracker.db; MariaDB/MySQL is selected withDatabase:Provider - Attachments: stored on disk under
DataRoot/Attachments/<jobId>/... - Optional local AI service:
tools/summarizer/(FastAPI) used by the API viaAi:BaseUrl
Quickstart (Docker)
This runs: frontend (nginx), backend API, the local AI service, and an Ollama container for hybrid CV block classification.
- Create a
.envfile next todocker-compose.yml(you can start from.env.example).
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
- UI:
http://localhost:3000 - API: available via the UI container reverse proxy at
http://localhost:3000/api/... - Persistent data: stored in the
jobtracker_dataDocker volume (mounted at/datain the API container)
Local development
Prereqs
- .NET SDK
9.x(API targetsnet9.0) - Node.js 24 and npm (matching CI)
- (Optional) Python 3.12 if running the AI service without Docker
1) Run the API
cd JobTrackerApi
dotnet restore --locked-mode
dotnet run
By default the API listens on http://localhost:5202 (see JobTrackerApi/Properties/launchSettings.json).
Local preflight before browser/UAT
Run the backend first from JobTrackerApi/, then use the preflight gate from the repo root:
bash scripts/s06-preflight.sh
The preflight assumes the dev pairing used by job-tracker-ui/src/api.ts and JobTrackerApi/appsettings.Development.json:
- UI origin:
http://localhost:3000 - API base:
http://localhost:5202/api
It first checks the anonymous GET /api/auth/config endpoint, then probes GET /api/admin/system for DB/Gmail/AI readiness.
If auth is enabled and /api/admin/system returns 401/403, export an admin bearer token first and rerun:
export AUTH_TOKEN="<admin bearer token>"
bash scripts/s06-preflight.sh
To obtain a local admin token in dev, log in against the API with the seeded admin email/password from JobTrackerApi/appsettings.Development.json (or your environment override) via POST /api/auth/login, then export only the returned access token. The script never prints token values. Use API_BASE if your API is not on the default dev port.
Seed acceptance-ready data
After preflight passes and you have a bearer token, seed one deterministic acceptance fixture for the /jobs → workspace → follow-up → dashboard/reminders rerun:
export AUTH_TOKEN="<bearer token>"
bash scripts/s06-acceptance-data.sh
The script reuses scripts/s06-preflight.sh, creates or reuses the acceptance company/job, saves tailored package material, ensures one deterministic recruiter-thread correspondence entry, schedules follow-up readiness, and prints the seeded ids/readiness summary without echoing the token.
If the placeholder development password no longer matches the local DB, use the real account for this environment or a bearer token from an already-authenticated local browser session.
2) Run the UI
cd job-tracker-ui
npm ci
npm run dev
The UI defaults to calling http://localhost:5202/api when running on localhost (see job-tracker-ui/src/api.ts).
3) Run the browser smoke suite
cd job-tracker-ui
npx playwright install chromium
npm run test:e2e
The suite uses http://localhost:3300, starts isolated API/SQLite and Next.js processes, then covers login, saved-job creation,
Career Workspace, and anonymous public-CV/PDF access. It is also a required CI gate.
4) (Optional) Run the AI service
The API calls a local FastAPI service to generate summaries. If it’s not running, the app still works (summary generation may be empty / best-effort).
With Docker (recommended):
# One command for local Ollama startup + pull + AI-service restart
OLLAMA_MODEL=qwen2.5:7b ./scripts/start-ollama-cv.sh
# Then start the rest of the app if needed
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d backend frontend
The first Ollama startup is usually quick, but the first model pull and first generation can take a while. After the model is cached in the ollama_data volume, later restarts are much faster.
Or run directly from tools/summarizer/ (see tools/summarizer/README.md).
Configuration
API settings (appsettings / env vars)
Common keys:
ConnectionStrings:JobTracker: overrides SQLite location (otherwise usesDataRoot/jobtracker.db)Data:Root: folder for the SQLite DB + exports (defaults to API content root)Data:AttachmentsRoot: override attachments folder (defaults to<Data:Root>/Attachments)CvExports:RetainDays: generated PDF retention in days (default30, clamped to1–365)Cors:Origins: list of allowed origins (defaults tohttp://localhost:3000; wildcard origins are rejected because requests use credentials)Ai:BaseUrl: AI service base URL (defaulthttp://127.0.0.1:8001)Workers:RulesEnabled,Workers:FollowUpRemindersEnabled,Workers:DailyExportEnabled,Workers:JobEnrichmentEnabled: explicit worker kill switches, all defaultfalse; do not enable before the prerequisites indocs/architecture/background-workers.mdExports:DailyEnabled: legacy daily-export setting; both it andWorkers:DailyExportEnabledmust be trueExports:DailyFolder: export destination (relative toData:Rootif not absolute)Exports:DailyHourLocal: local hour (0–23) when the daily export runsBackups:Enabled: enable/disable the automated SQLite backup job (defaulttrue)Backups:HourLocal: local hour (0–23) when the daily database backup runs (default3)Backups:RetainCount: how many backup files to keep in<Data:Root>/backups(default14)- Backups use SQLite
VACUUM INTO(consistent snapshot, safe with WAL). A catch-up backup runs at startup when none exists from the last 24 h. For MySQL/MariaDB configure external backups instead (seedeploy/MARIADB.md).
- Backups use SQLite
Auth:GoogleClientId: if set, enables JWT bearer validation for Google ID tokensAuth:JwtKey: secret used to sign local JWTs for username/password login (set via env varAuth__JwtKey)Auth:JwtIssuer: JWT issuer (defaultJobTrackerApi)Auth:JwtAudience: JWT audience (defaultjob-tracker-ui)Auth:JwtExpiresMinutes: access token lifetime in minutes (default720)Auth:AdminEmail/Auth:AdminPassword: optional seed admin user (created on startup if missing)Auth:AllowRegistration: allow self-service registration viaPOST /api/auth/register(defaultfalse)Auth:Require: iftrue, all endpoints require auth (except endpoints explicitly marked anonymous)Stripe:SecretKey,Stripe:PricePremium,Stripe:WebhookSecret: enable hosted Premium checkout, the customer portal, and signed subscription webhooks when all are configuredTranslation:Provider:none(default) orlibretranslateTranslation:LibreTranslate:BaseUrl: base URL for LibreTranslate (only if provider enabled)Translation:LibreTranslate:ApiKey: optional API key for LibreTranslateApp:PublicBaseUrl: the single external origin used for email links, OAuth callbacks, billing redirects, secure cookies, and production Host validation (Production requires HTTPS; local development defaults tohttp://localhost:3000)Auth:MicrosoftTenant: Microsoft application sign-in policy (common,organizations,consumers, or one tenant GUID); required in Production when Microsoft sign-in is enabled and distinct from Graph'sMicrosoft:TenantIdEmail:Enabled: enable SMTP sending (true/false)Email:SmtpHost: SMTP host (for Gmail:smtp.gmail.com)Email:SmtpPort: SMTP port (for Gmail:587)Email:SmtpUser: SMTP username (often your Gmail address)Email:SmtpPassword: SMTP password (for Gmail: use an App Password)Email:From: from address (default:Email:SmtpUser)Email:FromName: from name (default:Jobbjakt)Email:FollowUpReminders:Enabled: enable scheduled follow-up reminder emailsEmail:FollowUpReminders:UpcomingDays: how far ahead reminder emails look for upcoming follow-up dates (default2)
UI settings
NEXT_PUBLIC_API_BASE_URL: override the API base URL (example:http://localhost:5202/api)
The complete environment template is .env.example. Production requirements and restore/rollback procedures are maintained in deploy/README.md; current component boundaries and supported providers are documented in docs/architecture/current.md.
API endpoint reference
Base URL in local dev: http://localhost:5202 (all routes are under /api/...).
Authentication:
- If
Auth:GoogleClientIdis configured, most endpoints requireAuthorization: Bearer <google_id_token>. - If it’s not configured, endpoints are effectively anonymous.
Job applications (/api/jobapplications)
GET /api/jobapplications- Query:
page,pageSize(15/20/25),q,status,companyId,location,needsFollowUp,includeDeleted,deletedOnly,sortBy,sortDir - Returns a paged list of
JobApplicationDto(includes computed follow-up flags and short summary).
- Query:
GET /api/jobapplications/{id}- Returns a single
JobApplicationDto(includes computed follow-up flags and a “full” summary on demand).
- Returns a single
GET /api/jobapplications/board?includeDeleted=false- Returns all job applications (intended for a board/overview view).
GET /api/jobapplications/reminders?upcomingDays=7- Returns jobs that need follow-up / are in key statuses and have upcoming follow-up dates.
POST /api/jobapplications- Body:
CreateJobApplicationRequest(job title, company id, status, notes/description, follow-up fields, tags, attachments flags, etc.) - Creates a job application and a
JobEventof typeCreated.
- Body:
PUT /api/jobapplications/{id}- Body:
UpdateJobApplicationRequest - Updates an application; records a
StatusChangedevent if the status changed.
- Body:
PATCH /api/jobapplications/{id}/status- Body:
{ "status": "..." } - Updates only status; records
StatusChangedif it changed. The status is normalized against the canonical pipeline (casing + known synonyms likeInterviewing→Interview); unrecognized values are preserved as custom statuses.
- Body:
GET /api/jobapplications/pipeline- Returns the canonical ordered pipeline stages (
Applied, Waiting, Interview, Offer, Rejected, Ghosted) with display order and category (Active/Success/Closed). The UI renders board columns and status dropdowns from this single source of truth.
- Returns the canonical ordered pipeline stages (
PATCH /api/jobapplications/{id}/followup- Body:
{ "followUpAt": "2026-03-13T12:00:00Z" }(ornull) - Sets/clears follow-up date; records a
FollowUpSetevent.
- Body:
GET /api/jobapplications/{id}/history- Returns
JobEventhistory for the job.
- Returns
GET /api/jobapplications/{id}/timeline- Returns a unified timeline combining job events, correspondence, and attachments.
GET /api/jobapplications/stats- Returns totals, counts by status, applied-last-30-days, and average days since applied.
GET /api/jobapplications/{id}/match-score- Deterministic CV↔job keyword-coverage score (0–100) with matched/missing keywords and per-CV-section coverage. Missing skills also seed a checklist-backed job-specific learning path, so users can mark them learned or dismiss them. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is
GET /api/jobapplications/{id}/candidate-fit.)
- Deterministic CV↔job keyword-coverage score (0–100) with matched/missing keywords and per-CV-section coverage. Missing skills also seed a checklist-backed job-specific learning path, so users can mark them learned or dismiss them. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is
GET /api/jobapplications/{id}/status-suggestion- Deterministic status suggestion derived from the job's most recent inbound message (interview invite / offer / rejection). Returns a forward-only suggestion (
hasSuggestion,suggestedStatus,signal, …) orhasSuggestion: false. Applying it is a normalPATCH .../status— always user-confirmed.
- Deterministic status suggestion derived from the job's most recent inbound message (interview invite / offer / rejection). Returns a forward-only suggestion (
DELETE /api/jobapplications/{id}- Soft-deletes an application (
IsDeleted=true); records aDeletedevent.
- Soft-deletes an application (
POST /api/jobapplications/{id}/restore- Restores a soft-deleted application; records a
Restoredevent.
- Restores a soft-deleted application; records a
Companies (/api/companies)
GET /api/companies: list companiesGET /api/companies/{id}: company by idPOST /api/companies: create (idempotent by name; returns existing if already present)- Body:
{ "name": "...", "location": "...?", "source": "...?" }
- Body:
PUT /api/companies/{id}: update- Body includes recruiter details and
pipelineStage,lastContactedAt,nextContactAt
- Body includes recruiter details and
Correspondence (/api/correspondence)
GET /api/correspondence/{jobId}: list messages for a job (ordered by date)POST /api/correspondence: create message- Body:
{ "jobApplicationId": 1, "from": "...", "content": "...", "subject": "...?", "channel": "...?", "date": "..."? }
- Body:
Attachments (/api/attachments)
GET /api/attachments/{jobId}: list attachments for a jobGET /api/attachments/download/{id}: download an attachment by attachment idPOST /api/attachments: upload files (multipart/form-data)- Form fields:
jobId(int),files(one or more)
- Form fields:
PATCH /api/attachments/{id}: rename attachment- Body:
{ "fileName": "NewName.pdf" }
- Body:
DELETE /api/attachments/{id}: delete attachment record + best-effort delete file on disk
Rules (/api/rules)
GET /api/rules: get rule settings (auto-creates defaults on first request)PUT /api/rules: update rule settings (values are clamped to sane bounds)
Export (/api/export)
GET /api/export/jobs?format=json|csv&includeDeleted=false- Downloads a file (
job-tracker-export-YYYY-MM-DD.jsonor.csv).
- Downloads a file (
Backup (/api/backup)
POST /api/backup/encrypted- Returns an encrypted backup file (
.jtbackup). - Note: only implemented on Windows in this build (uses ASP.NET Data Protection / DPAPI).
- Returns an encrypted backup file (
Job import (/api/jobimport)
POST /api/jobimport/preview- Body:
{ "url": "https://..." } - Returns a parsed preview payload (
JobImportResult), if a matching plugin can parse it.
- Body:
Client error reporting (/api/client-errors)
POST /api/client-errors- Body:
{ errorId?, message?, stack?, componentStack?, url?, userAgent?, at? } - Logs frontend errors into the API logs (best-effort).
- Body:
Authentication (/api/auth)
GET /api/auth/config: returns auth feature flags for the UIPOST /api/auth/login: local email/password login (returns a signed JWT)POST /api/auth/register: local registration (only if enabled viaAuth:AllowRegistration=true)GET /api/auth/me: returns current user/claims summary for the UIPOST /api/auth/request-password-reset: sends reset email (requires SMTP enabled)POST /api/auth/reset-password: resets password using emailed token
Users (/api/users) (admin-only)
GET /api/users: list users + rolesPOST /api/users: create a user (and optionally roles)PUT /api/users/{id}/roles: replace roles for a userDELETE /api/users/{id}: delete userPOST /api/users/{id}/send-password-reset: send a reset email to the user
Notes for contributors
- The API applies EF Core migrations on startup for the configured SQLite database.
- The background rules engine may automatically transition jobs to
Ghostedbased on rule settings. - In Docker, the UI proxies
/api/*to the backend service (seejob-tracker-ui/nginx.conf).
Project status and planned work
This README documents released behaviour, not the roadmap. Validated findings and implementation status live in docs/audits/audit-remediation-backlog.md and docs/work-programmes/master-progress.md; archived phase documents are historical evidence and may describe superseded behaviour.