955182b7c2
Route public health checks to the API, backfill and synchronize job opportunities, stabilize SPA smoke tests, and document operator-only production steps.
114 lines
8.5 KiB
Markdown
114 lines
8.5 KiB
Markdown
# ADR-002 — Separating Job (the opportunity) from JobApplication (the pursuit)
|
|
|
|
- **Status:** Accepted (partially implemented)
|
|
- **Date:** 2026-07-17
|
|
- **Phase:** 0 (foundation corrections)
|
|
- **Supersedes:** the 0-byte placeholder previously at `docs/_archive/decisions/ADR-002-job-application-model.md`
|
|
|
|
---
|
|
|
|
## Context
|
|
|
|
`docs/MASTER_IMPLEMENTATION_GUIDE.md` names this as the product's #2 priority workflow:
|
|
|
|
```
|
|
Find Job → Import Job → Review Details → Prepare CV → Prepare Cover Letter
|
|
→ Add Supporting Files → Submit Application → Track Progress
|
|
```
|
|
|
|
`docs/01-glossary.md` is explicit that "A Job may exist before an application is submitted" and models the hierarchy as `Company → Job Opportunity → Application`.
|
|
|
|
**The code could not represent any of that.** The 2026-07-17 discovery audit found:
|
|
|
|
1. There was no `Job` entity. `JobApplication` was the only entity, carrying 43 members that mixed opportunity data (`JobTitle`, `Description`, `JobUrl`, `Salary*`, `Deadline`, `Tags`, `Location`) with application data (`Status`, `DateApplied`, `ResponseReceived`, `FollowUpAt`).
|
|
2. `JobPipeline.Stages` began at `Applied`. There was no `Saved`/`Interested`/`Preparing`.
|
|
3. `DateApplied` was non-nullable with a `DateTime.UtcNow` default.
|
|
|
|
Consequences:
|
|
|
|
- A job you had not applied to could not be tracked. The 6-step add-job wizard (`components/AddJobModal.tsx`) walks a user through *preparing* an application and then had nowhere to save it except `Applied` with a fabricated date.
|
|
- Applying twice to the same reposted role duplicated the entire job description.
|
|
- Teal — the market leader — offers `Saved` in its **free** tier. This was a table-stakes gap.
|
|
|
|
## Decision
|
|
|
|
Split `Job` from `JobApplication`, **additively and in stages**. Phase 0 lays the foundation only.
|
|
|
|
### Delivered in Phase 0
|
|
|
|
1. **`Job` entity** (`JobTrackerApi/Models/Job.cs`) — the opportunity: company, title, description, URL, location, salary, deadline, tags, plus `SavedAt`. Owner-scoped by the same deny-on-null global query filter as every other tenant entity.
|
|
2. **`JobApplication.JobId`** — a nullable FK to `Job`, `OnDelete: SetNull`.
|
|
3. **Prospect stages** — `JobPipeline` gained `PipelineCategory.Prospect` and the stages `Saved`(1), `Interested`(2), `Preparing`(3), ahead of `Applied`(4). No schema change: `Status` is a free-text column by deliberate prior design.
|
|
4. **`DateApplied` is now nullable**, and `SavedAt` was added to `JobApplication`.
|
|
5. **`JobPipeline.SyncAppliedDate`** — the single enforcement point for the invariant below.
|
|
|
|
### Explicitly NOT done in Phase 0
|
|
|
|
- **No dual-write.** Nothing writes or reads `Job` yet. `JobApplication` remains the sole source of truth for every read and write.
|
|
- **No backfill of `Job` rows.** The table ships empty.
|
|
- **No legacy columns dropped.** `JobApplication` keeps its full copy of the opportunity fields.
|
|
|
|
This keeps Phase 0 a pure schema-and-vocabulary change with **zero behavioural change** to existing workflows, which is what "unblock future phases safely" requires.
|
|
|
|
### Phase 1 status (2026-07-31)
|
|
|
|
Create and update paths now synchronize `Job`, and startup idempotently backfills a linked `Job` for every legacy application missing one. Reads and legacy columns remain unchanged until the production validation and observation release in `docs/operations/job-opportunity-cutover.md` complete.
|
|
|
|
### The invariant
|
|
|
|
> `DateApplied` is set **if and only if** the job has left the pre-application stages.
|
|
|
|
Enforced in exactly one place — `JobPipeline.SyncAppliedDate(job, now)` — called from all three status-write paths (`POST /jobapplications`, `PUT /jobapplications/{id}`, `PATCH /jobapplications/{id}/status`) so they cannot drift.
|
|
|
|
- Entering a real stage stamps `DateApplied` if unset.
|
|
- Moving **back** into a Prospect stage **clears** it.
|
|
|
|
The backward clear is deliberate. The alternative — a `Saved` job still carrying an applied date — would silently count it as applied in analytics and expose it to the follow-up/ghosting rules. The original date stays recoverable from the `StatusChanged` `JobEvent` history, so this is denormalised-field loss, not data loss.
|
|
|
|
## Consequences
|
|
|
|
### Safe by construction
|
|
|
|
- **`RulesEngine`** already whitelisted `Applied`/`Offer`/`Rejected`/`Waiting` and explicitly refused to ghost anything else, so prospects were never at risk. A `JobPipeline.IsProspect` guard was added anyway, ahead of any date arithmetic, plus a null-`DateApplied` guard on the `Applied` branch — a null must fail safe rather than read as "infinitely old" and auto-ghost the job. Covered by `RulesEngineProspectTests`.
|
|
- **`StageAnalytics`** already filtered to `PipelineCategory.Active`, so prospects drop out of time-in-stage automatically.
|
|
|
|
### Deliberate behaviour changes
|
|
|
|
- `DaysSince` is now `int?`, null for prospects. Returning `0` would render as "Applied 0 days ago" — a lie. The API DTO and the frontend `JobApplication` type follow; the UI renders `—`.
|
|
- Applied-volume analytics and average-days-since-applied now filter `DateApplied != null`, so prospects cannot drag the average toward zero or inflate applied counts.
|
|
- `JobFlowBar` omits the "Applied" milestone entirely when there is no applied date.
|
|
- Custom (non-canonical) statuses are **not** treated as prospects. They predate this split and have always counted as applied; assuming otherwise would silently drop them out of existing users' analytics.
|
|
|
|
### Costs accepted
|
|
|
|
- `JobApplication` temporarily carries both `JobId` and its own opportunity columns — real duplication, time-boxed to the Phase 1 cutover.
|
|
- An empty `Jobs` table ships. Preferred over a backfill that no code consumes and that a later, better-informed cutover might shape differently.
|
|
|
|
## Migration notes (read before the next migration)
|
|
|
|
`20260717071417_AddJobEntityAndProspectStages` was **hand-edited after scaffolding**. `dotnet ef migrations add` additionally emitted `CreateTable` for `TrustedDevices`, `TwoFactorRecoveryCodes`, `UserSessions` and `AddColumn` for six `AspNetUsers` columns (`Microsoft*`, `Totp*`).
|
|
|
|
Those tables **already exist in every real database** — they were provisioned by the idempotent reconciler in `StartupInitializationExtensions`, not by a migration, so the prior `ModelSnapshot` did not know them and the scaffolder diffed them as missing. Verified directly against the live dev database: all three tables are present. Leaving the scaffolded statements in would have failed the deploy with "table already exists". They were removed; the reconciler still creates them on a fresh boot via `CREATE TABLE IF NOT EXISTS`.
|
|
|
|
`IX_JobApplications_OwnerUserId_IsDeleted_Status` was removed for the same reason: the reconciler applies it, and MySQL needs a `Status(50)` prefix length that the scaffolded DDL does not carry (see the comment in `JobTrackerContext.OnModelCreating`).
|
|
|
|
**The regenerated snapshot now includes those tables, so future migrations will not re-scaffold them.** This ADR's migration is the one that closes that drift.
|
|
|
|
EF emits a warning that the `SavedAt` backfill `UPDATE` runs while a rebuild of `JobApplications` is pending. Verified empirically against a copy of the real dev database (13 rows): the migration applies cleanly, all 36 pre-existing columns survive the rebuild, `DateApplied` values are preserved, `SavedAt` backfills correctly with no `0001-01-01` sentinels remaining, and `DateApplied` ends up nullable.
|
|
|
|
## Phase 1 cutover plan
|
|
|
|
1. **Done:** dual-write `Job` on every persistence path (`JobApplicationsController` and Gmail suggestion creation; CSV import is preview-only).
|
|
2. **Done:** backfill one `Job` per existing `JobApplication`; link via `JobId`.
|
|
3. Flip reads to `Job`, one endpoint at a time.
|
|
4. Drop the duplicated opportunity columns from `JobApplication`.
|
|
5. Make `JobId` non-nullable.
|
|
|
|
A `Job` with no `JobApplication` row is the eventual clean representation of a saved-but-not-applied job. Until step 4, the Prospect stages on `JobApplication.Status` carry that meaning instead.
|
|
|
|
## Alternatives considered
|
|
|
|
- **Full split in Phase 0.** Rejected: touches 38 endpoints, the Kanban, the table, the rules engine and the wizard at once, with no staging environment and a straight-to-prod deploy. The discovery report ranked this the highest-risk item in the plan.
|
|
- **Prospect stages only, no `Job` entity.** Would have unblocked the workflow, but leaves the duplication that makes applying twice to one role copy the whole description, and defers the schema foundation the roadmap's Phase 3/4 depend on.
|
|
- **Keep `DateApplied` non-nullable, add `SavedAt` alongside.** Rejected: a saved job would still carry a fabricated applied date — precisely the defect this ADR exists to remove.
|