Closes the three operational blockers from the production readiness review. deploy.sh now takes a database backup before it builds, stops or replaces anything, and aborts the deploy if the backup fails — so no deploy proceeds without a restore point. Dumps are gzipped and timestamped into /opt/job-tracker/backups (override with BACKUP_DIR), so one deploy never overwrites an earlier backup. Credentials come from the existing connection string and travel via MYSQL_PWD, never on the command line, so they cannot reach the process list or the deploy log. A dump that is empty or missing CREATE TABLE is rejected, because a truncated file that looks like a restore point is worse than none. SQLite deployments get their data volume tarred instead. Nothing is ever deleted automatically; retention is documented as manual. deploy/README.md documents backup creation, location, retention, database restore, application rollback, and when to use which — restore and rollback kept distinct, because a bad deploy usually needs only the rollback and restoring would discard everything written since the dump. Health checks now cover backend and frontend, which previously had none. GET /health is anonymous, cheap, and deliberately does not touch the database: a health check that queried MariaDB would restart a healthy backend whenever the database blipped, and would hand out an unauthenticated way to probe database availability. The backend image gains curl on the existing chromium apt layer, since the aspnet runtime ships neither curl nor wget. frontend now waits for backend to be healthy rather than merely started, because nginx proxies /api and refuses to start when the upstream cannot be resolved. Verified against real containers, no production data: backup from a seeded MariaDB 11; restore into a clean MariaDB 11 with rows identical; bad credentials and a missing connection string both abort non-zero and leave no partial file; SQLite volume backup produces a readable archive; backend and frontend both reach healthy; and a backend pointed at an unreachable database exits and is reported unhealthy, so a broken deploy cannot present as a running stack. Incidentally confirmed the earlier authorization work: with Auth:Require unset, /health returns 200 while /api/jobapplications returns 401. 393 backend tests pass. 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.