Files
jobtrackingapp/docs/decisions/ADR-002-job-application-model.md
T
cesnimda 955182b7c2
CI and Deploy / test (pull_request) Failing after 1m22s
CI and Deploy / deploy (pull_request) Has been skipped
fix: close release preflight gaps
Route public health checks to the API, backfill and synchronize job opportunities, stabilize SPA smoke tests, and document operator-only production steps.
2026-07-31 20:18:30 +02:00

8.5 KiB

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 stagesJobPipeline 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.