feat: introduce CV variant schema, dual-written from tailored CV saves

Phase F2 of the Career Workspace roadmap: CvVariant, CvVersion, and
TailoredApplication -- the reference seam. Per the product boundary,
a job application REFERENCES a tailored output; it does not own it.
CvVariant is not job-owned: it survives job deletion (SetNull on its
optional CareerProfile link, not cascaded), can be reused across
applications, and carries its own append-only CvVersion history.
TailoredApplication is the join that links a variant to a job
(cascades with either side, since the link is meaningless without
both).

Rather than shipping empty tables with no consumer, this dual-writes
from both existing TailoredCvDraft save paths (SaveTailoredCvDraft,
UpsertGeneratedTailoredCvDraftAsync via GenerateTailoredCvDraft) --
same pattern as CareerProfile in Phase F1. TailoredCvDraft remains
authoritative for every existing read path; the sync is additive and
never blocks or fails a draft save.

2 new tests: variant/version/link created on first save, same variant
reused (not duplicated) with version incrementing on subsequent
saves. Verified against the real dev DB -- FK dependency ordering
(CareerProfiles -> CvVariants -> CvVersions/TailoredApplications)
holds in both SQLite and MySQL reconciler dialects.
This commit is contained in:
cesnimda
2026-07-12 15:55:44 +02:00
parent 00c7e0b6ca
commit 47d05ba946
5 changed files with 388 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
namespace JobTrackerApi.Models;
// Career Workspace foundation, Phase F2 (career-workspace-implementation-roadmap.md). A CvVariant
// is a durable, named lens on the career profile -- unlike TailoredCvDraft, it is not owned by a
// job; TailoredApplication is the reference that links a variant to a job, so the same variant can
// be reused across applications and a job can be deleted without losing the variant.
//
// This phase dual-writes from the existing TailoredCvDraft save paths (same pattern as
// CareerProfile in Phase F1): every draft save also upserts the job's CvVariant, appends a
// CvVersion snapshot, and ensures a TailoredApplication link. TailoredCvDraft remains the
// authoritative row every existing read path uses.
public sealed class CvVariant
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public int? CareerProfileId { get; set; }
public CareerProfile? CareerProfile { get; set; }
public string Name { get; set; } = string.Empty;
// Serialized TailoredCvDocument -- reuses the existing document shape rather than inventing a
// new one, so this phase carries zero data-shape risk.
public string ContentJson { get; set; } = string.Empty;
public string ThemeId { get; set; } = "ats-minimal";
public int Version { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
// Append-only history: one row per CvVariant save.
public sealed class CvVersion
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public int CvVariantId { get; set; }
public CvVariant? CvVariant { get; set; }
public int Version { get; set; }
public string ContentJson { get; set; } = string.Empty;
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
// The integration seam per the product boundary: a job application REFERENCES a tailored output,
// it does not own it. Deleting a job does not delete the variant; the variant can outlive the job
// or be reused by a future one.
public sealed class TailoredApplication
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public int CvVariantId { get; set; }
public CvVariant? CvVariant { get; set; }
public int JobApplicationId { get; set; }
public JobApplication? JobApplication { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}