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
@@ -436,9 +436,62 @@ Canonical profile:
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
job.TailoredCvUpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
await SyncCvVariantFromDraftAsync(user.Id, job, document, cancellationToken);
return draft;
}
// Career Workspace foundation, Phase F2 (career-workspace-implementation-roadmap.md).
// Dual-writes a CvVariant + CvVersion + TailoredApplication whenever a TailoredCvDraft is
// saved, so the new tables are populated from real usage without needing new UI yet.
// TailoredCvDraft remains authoritative; this never blocks or fails draft saves.
private async Task SyncCvVariantFromDraftAsync(string ownerUserId, JobApplication job, TailoredCvDocument document, CancellationToken cancellationToken)
{
var existingLink = await _db.TailoredApplications
.Include(x => x.CvVariant)
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.JobApplicationId == job.Id, cancellationToken);
var variant = existingLink?.CvVariant;
if (variant is null)
{
var companyName = job.Company?.Name ?? (await _db.Companies.FirstOrDefaultAsync(c => c.Id == job.CompanyId, cancellationToken))?.Name;
var careerProfileId = await _db.CareerProfiles.Where(x => x.OwnerUserId == ownerUserId).Select(x => (int?)x.Id).FirstOrDefaultAsync(cancellationToken);
variant = new CvVariant
{
OwnerUserId = ownerUserId,
CareerProfileId = careerProfileId,
Name = string.Join(" @ ", new[] { job.JobTitle, companyName }.Where(x => !string.IsNullOrWhiteSpace(x))),
};
_db.CvVariants.Add(variant);
await _db.SaveChangesAsync(cancellationToken);
}
variant.ContentJson = JsonSerializer.Serialize(document);
variant.ThemeId = string.IsNullOrWhiteSpace(document.TemplateId) ? "ats-minimal" : document.TemplateId;
variant.Version += 1;
variant.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
_db.CvVersions.Add(new CvVersion
{
OwnerUserId = ownerUserId,
CvVariantId = variant.Id,
Version = variant.Version,
ContentJson = variant.ContentJson,
});
if (existingLink is null)
{
_db.TailoredApplications.Add(new TailoredApplication
{
OwnerUserId = ownerUserId,
CvVariantId = variant.Id,
JobApplicationId = job.Id,
});
}
await _db.SaveChangesAsync(cancellationToken);
}
private async Task<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix)
{
var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70);
@@ -2696,6 +2749,7 @@ Candidate master CV:
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
job.TailoredCvUpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
await SyncCvVariantFromDraftAsync(user.Id, job, document, cancellationToken);
return NoContent();
}