Files
jobtrackingapp/docs/architecture/career-profile-model.md
T
cesnimda f0b9b222ff
CI and Deploy / test (pull_request) Successful in 4m39s
CI and Deploy / deploy (pull_request) Has been skipped
fix(career): preserve reviewed profile values
Keep extraction heuristics out of manual save, version, and import paths so reviewed locations, URLs, dates, and languages round-trip unchanged.
2026-08-15 16:56:31 +02:00

12 KiB

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.

Reviewed-value persistence boundary (2026-08-15)

Extraction payloads continue through StructuredCvProfileJson.Normalize, which applies heuristics to reject parser noise and infer locations, URLs, roles, dates and languages. Once a profile reaches the editable Career surface, it is reviewed user data: NormalizeForPersistence, SerializePersisted and DeserializePersisted only trim/dedupe structural values and must not reinterpret them. This separation prevents a manual website path, free-form date or location such as "Remote across Europe" from changing on save, version restore, import merge or a legacy projection read. CareerProfileValidator rejects oversized reviewed values explicitly instead of silently discarding them.

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.