ce76046a29
- consolidate API ownership and remove dead vendor code - add Stripe billing, learning paths, and public CV hardening - add migration, recovery, security, audit, and browser gates
213 lines
11 KiB
Markdown
213 lines
11 KiB
Markdown
# Career Profile Data Model
|
|
|
|
> Phase 3 foundation (2026-07-18). Defines the structured career-profile system: entities,
|
|
> relationships, ownership, source-of-truth, and snapshot rules. This is the model the CV Builder,
|
|
> tailored CVs, cover letters, portfolio, and interview prep will all consume in later phases.
|
|
>
|
|
> Companion to `docs/architecture/current.md` §4a (profile/career separation) and
|
|
> `docs/decisions/ADR-002-job-application-model.md`. Verified against code on 2026-07-18.
|
|
|
|
> ## Implementation status (2026-07-18) — SHIPPED
|
|
>
|
|
> The model below is implemented on `main` and verified against the live database:
|
|
> - **Entities** (`JobTrackerApi/Models/CareerEntities.cs`): `CareerExperience`, `CareerEducation`, `CareerSkill`,
|
|
> `CareerProject`, `CareerCertification`, `CareerLanguage` — relational children of `CareerProfile`;
|
|
> long tail in `CareerProfile.LongTailJson`. Migration `AddCareerProfileRelationalChildren`
|
|
> (applied cleanly on the real dev DB and the running container).
|
|
> - **Projection** (`CareerProfileService` + `CareerProfileMapper`): relational is the source of
|
|
> truth; `SaveVersionAsync` syncs children + long tail; `LoadStructuredAsync` reads relationally
|
|
> with lazy backfill from the blob; the `ProfileCvStructureJson` blob is kept as the derived
|
|
> projection.
|
|
> - **API** (`CareerProfileController`, `/api/career/profile`): GET/PUT profile, GET completeness,
|
|
> GET versions, POST version restore. Validation via `CareerProfileValidator` (abuse limits).
|
|
> - **Frontend** (`/career`): reads/writes the relational API, profile-completeness overview,
|
|
> version-history restore.
|
|
> - **Tests**: 23 career-focused backend tests (round-trip, item-key stability, replace-all,
|
|
> backfill, completeness, validation, versioning/restore) + frontend save/completeness tests.
|
|
>
|
|
> Not yet done (later): promote the long tail to relational if needed; a full section-by-section UX
|
|
> redesign of the editor; the eventual removal of the derived blob once every reader flips.
|
|
|
|
---
|
|
|
|
## 1. Where we start (F1, on `main`)
|
|
|
|
The Career Workspace foundation (`992f89e`) stores the whole profile as **one JSON blob**:
|
|
|
|
- `ApplicationUser.ProfileCvStructureJson` — the **authoritative** column every existing read path
|
|
uses (the `/career` UI, CV rendering, tailoring, match-score, cover-letter drafts).
|
|
- `CareerProfile.ProfileJson` — a **mirror** of the same `StructuredCvProfile` shape, written by
|
|
`CareerProfileService.SaveVersionAsync` (dual-write). One row per user.
|
|
- `CareerProfileVersion` — append-only history: one row per save, with a `Source` discriminator.
|
|
|
|
The blob shape (`JobTrackerApi/Models/StructuredCvProfile.cs`) already has structured items for **Jobs, Education,
|
|
Certifications, Projects, Languages**, plus `Skills`/`Summary`/`Interests` (string lists) and
|
|
`OtherSections` (title + items). `CareerProfileService` already assigns **stable item IDs** to
|
|
Jobs/Education/Certifications/Projects and normalizes free-text dates to `YYYY-MM`.
|
|
|
|
**The stable IDs are the seam.** They were added in F1 precisely so a relational model (and later CV
|
|
variants) can reference "this job" by identity instead of array position.
|
|
|
|
---
|
|
|
|
## 2. Target model (Phase 3)
|
|
|
|
Promote the well-structured, queryable career items to **relational child tables** of
|
|
`CareerProfile`. Keep the loosely-structured long tail as JSON on `CareerProfile`.
|
|
|
|
### Decision: which entities are relational vs JSON
|
|
|
|
Per the settled product decision (2026-07-17: "relational for Experience/Education/Skills/Projects;
|
|
JSON for the long tail") **and** the Phase 3 brief's "keep flexible JSON/custom sections":
|
|
|
|
**Relational child tables** (clear structured shape, queried/sorted/edited item-by-item):
|
|
|
|
| Entity | From blob | Key fields |
|
|
|---|---|---|
|
|
| `CareerExperience` | `Jobs` | Title, Company, Location, Start/End (+ normalized), IsCurrent, Bullets, Skills |
|
|
| `CareerEducation` | `Education` | Qualification, Level, Institution, Location, Start/End, Details |
|
|
| `CareerSkill` | `Skills` | Name, Category, Proficiency |
|
|
| `CareerProject` | `Projects` | Name, Role, Location, Start/End, Bullets, Skills, Links |
|
|
| `CareerCertification` | `Certifications` | Name, Issuer, Date (+ normalized), Details |
|
|
| `CareerLanguage` | `Languages` | Name, Level, Notes |
|
|
|
|
**JSON on `CareerProfile`** (the long tail — flexible, low-query-value, or not yet shape-stable):
|
|
|
|
- `Contact` (name, headline, email, phone, location, website, linkedin) — a single value object.
|
|
- `Summary` (string list), `Interests` (string list).
|
|
- `Achievements`, `Organisations`, `Publications`, `Courses` — kept in a `LongTailJson` blob for
|
|
now. The Phase 3 brief lists these as relational-*recommended*; they have **no structured shape
|
|
in the current model** and low query value, so they start as JSON and can be **promoted to
|
|
relational later** without a source-of-truth change (they already live under `CareerProfile`).
|
|
- `CustomSections` / `OtherSections` — arbitrary title + items, JSON by nature.
|
|
- `Metadata` (per-field confidence/provenance from AI extraction) — JSON.
|
|
|
|
> This split is a decision, not a guess. If the user wants Achievements/Organisations/Publications/
|
|
> Courses relational now, that is an additive change (new child tables under the same
|
|
> `CareerProfile`) — flagged here rather than silently chosen.
|
|
|
|
### Entity shape (child tables)
|
|
|
|
Every child table carries:
|
|
|
|
- `Id` (int, PK, autoincrement).
|
|
- `CareerProfileId` (FK → `CareerProfile`, cascade delete).
|
|
- `OwnerUserId` (denormalized for the tenant query filter — same pattern as every other owned entity).
|
|
- `ItemKey` (string) — the **stable item ID** carried over from the blob, so a row keeps its identity
|
|
across imports/edits and future CV variants can reference it.
|
|
- `SortOrder` (int) — explicit ordering (the blob used array position; relational needs it explicit).
|
|
- Its domain fields.
|
|
|
|
Free-text date fields keep the existing pattern: the original string (`Start`, `End`) **and** a
|
|
best-effort `YYYY-MM` normalization (`StartDate`, `EndDate`), never one replacing the other.
|
|
|
|
---
|
|
|
|
## 3. Relationships
|
|
|
|
```
|
|
ApplicationUser (1) ──owns──> (1) CareerProfile ──> (many) CareerProfileVersion [append-only history]
|
|
│
|
|
├──> (many) CareerExperience
|
|
├──> (many) CareerEducation
|
|
├──> (many) CareerSkill
|
|
├──> (many) CareerProject
|
|
├──> (many) CareerCertification
|
|
└──> (many) CareerLanguage
|
|
|
|
CareerProfile also holds: Contact, Summary, Interests, Achievements, Organisations,
|
|
Publications, Courses, CustomSections, Metadata (all JSON columns)
|
|
```
|
|
|
|
- One `CareerProfile` per user (unique index on `OwnerUserId` — already enforced in F1).
|
|
- Child rows cascade-delete with the profile.
|
|
- Deleting a `CareerProfile` never touches `JobApplication`/`TailoredApplication` — those *reference*
|
|
career outputs, they don't own them (same rule as ADR-002).
|
|
|
|
---
|
|
|
|
## 4. Ownership & source of truth
|
|
|
|
**`CareerProfile` (its relational children + JSON long tail) is the ONLY editable career source.**
|
|
Everything downstream is derived and must never be hand-edited as if it were the source:
|
|
|
|
```
|
|
CareerProfile (editable — the master profile)
|
|
↓ derive
|
|
CV Variant (a lens: selections + overrides referencing CareerProfile item keys) [Phase 3/4]
|
|
↓ derive
|
|
Generated CV (rendered from a variant + theme) [Phase 4]
|
|
↓ snapshot
|
|
Application Snapshot (frozen copy attached to a job application) [Phase 3/4]
|
|
↓ export
|
|
PDF / DOCX
|
|
```
|
|
|
|
### The transition rule (source-of-truth timing)
|
|
|
|
Existing read paths (CV rendering, tailoring, match-score, cover letters) read
|
|
`ApplicationUser.ProfileCvStructureJson`. We do **not** rewrite all of them in Phase 3. Instead:
|
|
|
|
1. **The relational model becomes the editable source of truth.** The `/career` structured editor
|
|
reads and writes the relational tables.
|
|
2. **On every save, the relational model is serialized back into the `StructuredCvProfile` blob**
|
|
(`ApplicationUser.ProfileCvStructureJson` + `CareerProfile.ProfileJson`). The blob becomes a
|
|
**derived read-model** — a projection kept for the legacy read paths — not an independently
|
|
editable source.
|
|
3. This keeps "do not duplicate career information" honest: there is exactly **one editable copy**
|
|
(relational); the blob is a generated projection, like a CV is.
|
|
4. The eventual removal of the blob (once every reader is migrated to read relational) is a later
|
|
phase and out of scope here.
|
|
|
|
This mirrors the additive, non-destructive philosophy of Phase 0/ADR-002: introduce the new model,
|
|
keep the old surface working via a derived projection, flip readers later.
|
|
|
|
---
|
|
|
|
## 5. Snapshot rules
|
|
|
|
- **`CareerProfileVersion`** (exists) — an append-only **history** of the whole profile, one row per
|
|
save, tagged with `Source` (`manual` | `import` | `ai` | `rebuild` | …). It stores the serialized
|
|
`StructuredCvProfile` (a snapshot blob) — correct: a version is an immutable point-in-time record,
|
|
not something queried field-by-field. Used for restore (Phase 5) and to make AI/import changes
|
|
reversible.
|
|
- **Application snapshots** (later phase) — when a tailored CV is attached to a job application, it
|
|
is a **frozen snapshot** of the derived output, independent of later profile edits. The master
|
|
profile changing must never retroactively alter a submitted application.
|
|
- **Rule:** history snapshots and application snapshots are always *copies*, never live references to
|
|
the editable profile. Only the `CareerProfile` relational model is live-editable.
|
|
|
|
---
|
|
|
|
## 6. Migration (blob → relational)
|
|
|
|
**Non-destructive, additive.** The relational tables are empty today (F1 tables have 0 rows);
|
|
`ProfileCvStructureJson` holds the real data.
|
|
|
|
- On first access of a user's structured profile after Phase 3 ships, if the relational tables are
|
|
empty for that user, **backfill them from the blob** (parse `StructuredCvProfile` → child rows,
|
|
carrying the stable item IDs into `ItemKey`).
|
|
- The blob is **retained** as the derived projection (see §4), so nothing that reads it breaks.
|
|
- No column is dropped, no data is overwritten. The backfill is idempotent (keyed on `ItemKey`).
|
|
|
|
> This is a real data migration against the user's actual profile data. It is additive and
|
|
> reversible (the blob remains authoritative for readers until each is flipped), but the backfill
|
|
> step is the point to confirm before running against production — see the Phase 3 execution notes.
|
|
|
|
---
|
|
|
|
## 7. What this explicitly is NOT (Phase 3 boundaries)
|
|
|
|
Not built here (they belong to Phase 4): CV themes, PDF/DOCX generation, the CV Builder UI, AI
|
|
rewriting, CV variants. Phase 3 delivers only the **editable master profile** — the foundation those
|
|
consume.
|
|
|
|
---
|
|
|
|
## 8. Open decisions (surface before/at implementation)
|
|
|
|
1. **Long-tail scope.** Achievements/Organisations/Publications/Courses start as JSON (§2). Promote
|
|
to relational now, or defer? Additive either way.
|
|
2. **Backfill timing** (§6) — run the blob→relational backfill lazily on first access (recommended,
|
|
zero-downtime) vs a one-shot migration. Confirm before running against production data.
|