Phase 5.4. Connects the career outputs a user already has to one job
application, without building a second copy of any of them.
The flow is strictly one-directional — CareerProfile -> CvVariant ->
application output — and nothing writes back up. No code path in this phase
touches CareerProfile or its children.
CV integration re-points rather than duplicates. GET/PUT /{id}/cv attaches one
variant to an application via CvVariant.JobApplicationId; replacing detaches the
previous variant instead of deleting it. Creating, duplicating, editing, theming,
previewing, exporting PDF and version history all stay in the existing CV
builder, which the section links into. There is no second CV system.
Tailoring composes the Phase 5.3 analysis and match into skills to highlight,
experience to prioritise, projects to emphasise, keywords to include and gaps to
address. Deterministic and advisory: it says what the user could emphasise and
the user edits the variant themselves. Nothing auto-applies.
Cover letters gain the history they were missing. JobApplication.CoverLetterText
stays the current text with its API contract unchanged; CoverLetterVersions
records what it used to be, so an AI rewrite is never destructive. Restore is
additive — the old text comes back as a new version, so what you restored from
still exists. Source and AiAction record whether the user wrote a version or
approved it from a suggestion, and an AI generation only becomes a version once
the user saves it.
Documents are untouched: the existing Attachment system already covers CV, cover
letter, certificates and portfolio files with a Purpose field, so the workspace
mounts that component rather than adding a second upload path.
CoverLetterVersions is the only new table — reconciler-owned, no-op migration,
guarded on JobApplications, and verified on a fresh MariaDB 11: int
AUTO_INCREMENT primary key, varchar(255) owner, datetime(6), composite index
inside the key limit.
360 backend tests, 115 frontend tests, type check, Release build and the
production build all pass locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Job Tracker
Job Tracker is a simple, self-hosted app for tracking job applications with a React frontend and an ASP.NET Core API backed by SQLite.
Features (high level)
- Track job applications (status, applied date, notes, tags, follow-up dates, deadlines, salary, links, etc.)
- 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
Architecture
job-tracker-ui/: React app (runs onhttp://localhost:3000in dev)JobTrackerApi/: ASP.NET Core API (defaults tohttp://localhost:5202)- SQLite DB file: defaults to
JobTrackerApi/jobtracker.dbunlessData:Root/ connection string overrides it - 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 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 (for the UI)
- (Optional) Python 3.x if running the AI service without Docker
1) Run the API
cd JobTrackerApi
dotnet restore
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 install
npm start
The UI defaults to calling http://localhost:5202/api when running on localhost (see job-tracker-ui/src/api.ts).
3) (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 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)Cors:Origins: list of allowed origins (defaults tohttp://localhost:3000; use"*"to allow all)Ai:BaseUrl: AI service base URL (defaulthttp://127.0.0.1:8001)Exports:DailyEnabled: enable/disable daily export background jobExports: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)Translation:Provider:none(default) orlibretranslateTranslation:LibreTranslate:BaseUrl: base URL for LibreTranslate (only if provider enabled)Translation:LibreTranslate:ApiKey: optional API key for LibreTranslateApp:PublicBaseUrl: public base URL used when generating links in emails (example:https://jobs.cesnimda.uk)Email: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
REACT_APP_API_BASE_URL: override the API base URL (example:http://localhost:5202/api)
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. 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. 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).
Ideas to improve the app (next steps)
- Add first-class “timeline” view combining
JobEventhistory + correspondence + attachments into a single chronological stream (this also naturally supports the “Applied → Interview → Reply → …” flow you mentioned). - Define a canonical pipeline/status model (enum + ordering) and drive UI badges/board columns from it; allow custom pipelines per user.
- Add Swagger/OpenAPI for the controllers (so endpoint docs stay in sync) + include example requests/responses.
- Add validation + problem-details responses consistently (and keep request/response DTOs stable and versioned).
- Add search improvements (full-text search, filter by tags, filter by date ranges, saved views).
- Add notifications (email/desktop) for follow-ups and upcoming deadlines.