# 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.)
- 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/`: React app (runs on `http://localhost:3000` in dev)
- `JobTrackerApi/`: ASP.NET Core API (defaults to `http://localhost:5202`)
- SQLite DB file: defaults to `JobTrackerApi/jobtracker.db` unless `Data:Root` / connection string overrides it
- Attachments: stored on disk under `DataRoot/Attachments/<jobId>/...`
- Optional local AI service: `tools/summarizer/` (FastAPI) used by the API via `Ai:BaseUrl`

## Quickstart (Docker)

This runs: frontend (nginx), backend API, the local AI service, and an Ollama container for hybrid CV block classification.

1) Create a `.env` file next to `docker-compose.yml` (you can start from `.env.example`).

```bash
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_data` Docker volume (mounted at `/data` in the API container)

## Local development

### Prereqs

- .NET SDK `9.x` (API targets `net9.0`)
- Node.js (for the UI)
- (Optional) Python 3.x if running the AI service without Docker

### 1) Run the API

```bash
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
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:

```bash
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:

```bash
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

```bash
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) Run the browser smoke suite

```bash
cd job-tracker-ui
npx playwright install chromium
npm run test:e2e
```

The suite 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):

```bash
# 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 uses `DataRoot/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 (default `30`, clamped to `1`–`365`)
- `Cors:Origins`: list of allowed origins (defaults to `http://localhost:3000`; wildcard origins are rejected because requests use credentials)
- `Ai:BaseUrl`: AI service base URL (default `http://127.0.0.1:8001`)
- `Workers:RulesEnabled`, `Workers:FollowUpRemindersEnabled`, `Workers:DailyExportEnabled`, `Workers:JobEnrichmentEnabled`: explicit worker kill switches, all default `false`; do not enable before the prerequisites in `docs/architecture/background-workers.md`
- `Exports:DailyEnabled`: legacy daily-export setting; both it and `Workers:DailyExportEnabled` must be true
- `Exports:DailyFolder`: export destination (relative to `Data:Root` if not absolute)
- `Exports:DailyHourLocal`: local hour (0–23) when the daily export runs
- `Backups:Enabled`: enable/disable the automated SQLite backup job (default `true`)
- `Backups:HourLocal`: local hour (0–23) when the daily database backup runs (default `3`)
- `Backups:RetainCount`: how many backup files to keep in `<Data:Root>/backups` (default `14`)
  - 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 (see `deploy/MARIADB.md`).
- `Auth:GoogleClientId`: if set, enables JWT bearer validation for Google ID tokens
- `Auth:JwtKey`: secret used to sign local JWTs for username/password login (set via env var `Auth__JwtKey`)
- `Auth:JwtIssuer`: JWT issuer (default `JobTrackerApi`)
- `Auth:JwtAudience`: JWT audience (default `job-tracker-ui`)
- `Auth:JwtExpiresMinutes`: access token lifetime in minutes (default `720`)
- `Auth:AdminEmail` / `Auth:AdminPassword`: optional seed admin user (created on startup if missing)
- `Auth:AllowRegistration`: allow self-service registration via `POST /api/auth/register` (default `false`)
- `Auth:Require`: if `true`, 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 configured
- `Translation:Provider`: `none` (default) or `libretranslate`
- `Translation:LibreTranslate:BaseUrl`: base URL for LibreTranslate (only if provider enabled)
- `Translation:LibreTranslate:ApiKey`: optional API key for LibreTranslate
- `App: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 to `http://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's `Microsoft:TenantId`
- `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 emails
- `Email:FollowUpReminders:UpcomingDays`: how far ahead reminder emails look for upcoming follow-up dates (default `2`)

### UI settings

- `NEXT_PUBLIC_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:GoogleClientId` is configured, most endpoints require `Authorization: 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).
- `GET /api/jobapplications/{id}`
  - Returns a single `JobApplicationDto` (includes computed follow-up flags and a “full” summary on demand).
- `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 `JobEvent` of type `Created`.
- `PUT /api/jobapplications/{id}`
  - Body: `UpdateJobApplicationRequest`
  - Updates an application; records a `StatusChanged` event if the status changed.
- `PATCH /api/jobapplications/{id}/status`
  - Body: `{ "status": "..." }`
  - Updates only status; records `StatusChanged` if it changed. The status is normalized against the canonical pipeline (casing + known synonyms like `Interviewing`→`Interview`); unrecognized values are preserved as custom statuses.
- `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.
- `PATCH /api/jobapplications/{id}/followup`
  - Body: `{ "followUpAt": "2026-03-13T12:00:00Z" }` (or `null`)
  - Sets/clears follow-up date; records a `FollowUpSet` event.
- `GET /api/jobapplications/{id}/history`
  - Returns `JobEvent` history for the job.
- `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`.)
- `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`, …) or `hasSuggestion: false`. Applying it is a normal `PATCH .../status` — always user-confirmed.
- `DELETE /api/jobapplications/{id}`
  - Soft-deletes an application (`IsDeleted=true`); records a `Deleted` event.
- `POST /api/jobapplications/{id}/restore`
  - Restores a soft-deleted application; records a `Restored` event.

### Companies (`/api/companies`)

- `GET /api/companies`: list companies
- `GET /api/companies/{id}`: company by id
- `POST /api/companies`: create (idempotent by name; returns existing if already present)
  - Body: `{ "name": "...", "location": "...?", "source": "...?" }`
- `PUT /api/companies/{id}`: update
  - Body includes recruiter details and `pipelineStage`, `lastContactedAt`, `nextContactAt`

### 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": "..."? }`

### Attachments (`/api/attachments`)

- `GET /api/attachments/{jobId}`: list attachments for a job
- `GET /api/attachments/download/{id}`: download an attachment by attachment id
- `POST /api/attachments`: upload files (multipart/form-data)
  - Form fields: `jobId` (int), `files` (one or more)
- `PATCH /api/attachments/{id}`: rename attachment
  - Body: `{ "fileName": "NewName.pdf" }`
- `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.json` or `.csv`).

### 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).

### Job import (`/api/jobimport`)

- `POST /api/jobimport/preview`
  - Body: `{ "url": "https://..." }`
  - Returns a parsed preview payload (`JobImportResult`), if a matching plugin can parse it.

### 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).

### Authentication (`/api/auth`)

- `GET /api/auth/config`: returns auth feature flags for the UI
- `POST /api/auth/login`: local email/password login (returns a signed JWT)
- `POST /api/auth/register`: local registration (only if enabled via `Auth:AllowRegistration=true`)
- `GET /api/auth/me`: returns current user/claims summary for the UI
- `POST /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 + roles
- `POST /api/users`: create a user (and optionally roles)
- `PUT /api/users/{id}/roles`: replace roles for a user
- `DELETE /api/users/{id}`: delete user
- `POST /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 `Ghosted` based on rule settings.
- In Docker, the UI proxies `/api/*` to the backend service (see `job-tracker-ui/nginx.conf`).

## Ideas to improve the app (next steps)

- Add first-class “timeline” view combining `JobEvent` history + 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.
