docs: reorganize tree, restore architecture + research from archive, add Phase 0 reports

Active docs/ was stub scaffolding while the real docs sat in docs/_archive/.
Restore and correct them, and record the Phase 0 work.

- docs/architecture/current.md: verified system map (from archived SYSTEM_OVERVIEW,
  9 corrections against code).
- docs/research/competitors.md: sourced competitor analysis (from archived
  PRODUCT_RESEARCH, feature matrix corrected).
- docs/decisions/ADR-002-job-application-model.md: the Job/JobApplication split.
- docs/application-discovery-report.md, docs/implementation-roadmap.md,
  docs/phase-0-foundation-report.md, docs/career-workspace-branch-assessment.md.
- Remove 10 zero-byte placeholder files that advertised content that never existed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-17 17:04:32 +02:00
parent aa3567d8a8
commit b176a44627
275 changed files with 12554 additions and 0 deletions
@@ -0,0 +1,109 @@
# 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** (`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.
### 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. Dual-write `Job` on every create path (`JobApplicationsController.Create`, `GmailController`'s job creation, CSV import).
2. 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.