feat(career): CV builder backend — data-driven theme engine + variant model
CI and Deploy / test (push) Failing after 1m51s
CI and Deploy / deploy (push) Has been skipped

Phase 4 foundation. A CvVariant is a lens over the master CareerProfile
(section order/visibility, per-item overrides keyed by ItemKey, theme +
builder settings) — it references career data, never duplicates it. One
renderer (ThemedCvRenderer) draws every theme; a theme is pure data
(CvThemeCatalog, 8 professional themes), so adding a theme needs no renderer
change. Autosave version history + non-destructive restore, public CV via
/api/public-cv/{slug} (anonymous, noindex, filter-bypassing owner load), and
an AI-assist endpoint reusing the existing provider abstraction (suggestions
only, never auto-applied).

- Models: CvVariant/CvVariantVersion, CvVariantSettings, CvTheme + catalog
- Services: CvVariantResolver (profile+lens -> render model), ThemedCvRenderer,
  CvVariantService, CareerProfileService.LoadStructuredForOwnerAsync (public)
- API: CvVariantController (/api/cv), PublicCvController (/api/public-cv)
- Migration AddCvVariants (2 self-contained tables; verified applied on the
  running container), 16 tests (resolver/renderer/service), 296 backend green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 09:52:58 +02:00
parent 707d8c59d2
commit a3e18e4b44
15 changed files with 3842 additions and 0 deletions
@@ -30,6 +30,11 @@ public interface ICareerProfileService
// and the current state are both preserved; the restore is itself reversible). Returns the
// restored profile, or null if the version does not exist.
Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken);
// Read-only structured load for a specific owner, bypassing the tenant query filter. For public
// CV rendering (/cv/{slug}) where there is no authenticated current user. Never writes, never
// backfills — falls back to the ProfileJson blob if relational rows are absent.
Task<StructuredCvProfile> LoadStructuredForOwnerAsync(string ownerUserId, CancellationToken cancellationToken);
}
public sealed record CareerProfileVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
@@ -120,6 +125,29 @@ public sealed class CareerProfileService : ICareerProfileService
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
}
public async Task<StructuredCvProfile> LoadStructuredForOwnerAsync(string ownerUserId, CancellationToken cancellationToken)
{
var profile = await _db.CareerProfiles.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (profile is null) return new StructuredCvProfile();
var experiences = await _db.CareerExperiences.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var education = await _db.CareerEducations.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var skills = await _db.CareerSkills.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var projects = await _db.CareerProjects.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var certifications = await _db.CareerCertifications.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var languages = await _db.CareerLanguages.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
// No relational rows yet (a pre-Phase-3 profile that has never been re-saved): fall back to
// the blob so a public CV still renders. Read-only, so we don't backfill here.
if (experiences.Count == 0 && education.Count == 0 && skills.Count == 0 && projects.Count == 0
&& certifications.Count == 0 && languages.Count == 0 && !string.IsNullOrWhiteSpace(profile.ProfileJson))
{
return StructuredCvProfileJson.Deserialize(profile.ProfileJson);
}
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
}
public async Task<IReadOnlyList<CareerProfileVersionInfo>> ListVersionsAsync(string ownerUserId, CancellationToken cancellationToken)
{
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);