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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user