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
+199
View File
@@ -0,0 +1,199 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed record CvVariantSummary(int Id, string Name, string ThemeId, string PublicSlug, bool IsPublic, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc);
public sealed record CvVariantVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
// Person + photo needed to render, resolved from ApplicationUser by the controller so this service
// stays off the Identity store.
public sealed record CvRenderPerson(string FallbackName, string? PhotoDataUrl);
public interface ICvVariantService
{
Task<IReadOnlyList<CvVariantSummary>> ListAsync(string ownerUserId, CancellationToken ct);
Task<CvVariant?> GetAsync(string ownerUserId, int id, CancellationToken ct);
Task<CvVariant> CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct);
Task<CvVariant?> SaveAsync(string ownerUserId, int id, string? name, CvVariantSettings settings, string source, CancellationToken ct);
Task<CvVariant?> SetPublicAsync(string ownerUserId, int id, bool isPublic, CancellationToken ct);
Task<CvVariant?> DuplicateAsync(string ownerUserId, int id, string? newName, CancellationToken ct);
Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct);
Task<IReadOnlyList<CvVariantVersionInfo>> ListVersionsAsync(string ownerUserId, int id, CancellationToken ct);
Task<CvVariant?> RestoreVersionAsync(string ownerUserId, int id, int version, CancellationToken ct);
// Render a saved variant against the current master profile.
Task<ThemedCvRenderResult?> RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct);
// Render arbitrary (unsaved) settings against the current master profile — live preview.
Task<ThemedCvRenderResult> RenderSettingsAsync(string ownerUserId, CvVariantSettings settings, CvRenderPerson person, CancellationToken ct);
// Render a public variant by slug (anonymous). Returns null if the slug is unknown or not public.
Task<(ThemedCvRenderResult render, string ownerUserId)?> RenderPublicAsync(string publicSlug, CvRenderPerson person, CancellationToken ct);
Task<string?> GetPublicOwnerAsync(string publicSlug, CancellationToken ct);
}
public sealed class CvVariantService : ICvVariantService
{
private readonly JobTrackerContext _db;
private readonly ICareerProfileService _career;
private readonly IThemedCvRenderer _renderer;
public CvVariantService(JobTrackerContext db, ICareerProfileService career, IThemedCvRenderer renderer)
{
_db = db;
_career = career;
_renderer = renderer;
}
public async Task<IReadOnlyList<CvVariantSummary>> ListAsync(string ownerUserId, CancellationToken ct)
{
var variants = await _db.CvVariants.Where(x => x.OwnerUserId == ownerUserId)
.OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(ct);
return variants.Select(Summarize).ToList();
}
public Task<CvVariant?> GetAsync(string ownerUserId, int id, CancellationToken ct) =>
_db.CvVariants.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct);
public async Task<CvVariant> CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;
var normalized = CvVariantSettingsJson.Normalize(settings);
var variant = new CvVariant
{
OwnerUserId = ownerUserId,
PublicSlug = NewSlug(),
Name = CleanName(name),
JobApplicationId = jobApplicationId,
SettingsJson = CvVariantSettingsJson.Serialize(normalized),
Version = 1,
CreatedAtUtc = now,
UpdatedAtUtc = now,
};
_db.CvVariants.Add(variant);
await _db.SaveChangesAsync(ct);
AppendVersion(variant, "create");
await _db.SaveChangesAsync(ct);
return variant;
}
public async Task<CvVariant?> SaveAsync(string ownerUserId, int id, string? name, CvVariantSettings settings, string source, CancellationToken ct)
{
var variant = await GetAsync(ownerUserId, id, ct);
if (variant is null) return null;
if (!string.IsNullOrWhiteSpace(name)) variant.Name = CleanName(name);
variant.SettingsJson = CvVariantSettingsJson.Serialize(settings);
variant.Version += 1;
variant.UpdatedAtUtc = DateTimeOffset.UtcNow;
AppendVersion(variant, string.IsNullOrWhiteSpace(source) ? "autosave" : source.Trim());
await _db.SaveChangesAsync(ct);
return variant;
}
public async Task<CvVariant?> SetPublicAsync(string ownerUserId, int id, bool isPublic, CancellationToken ct)
{
var variant = await GetAsync(ownerUserId, id, ct);
if (variant is null) return null;
variant.IsPublic = isPublic;
variant.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(ct);
return variant;
}
public async Task<CvVariant?> DuplicateAsync(string ownerUserId, int id, string? newName, CancellationToken ct)
{
var source = await GetAsync(ownerUserId, id, ct);
if (source is null) return null;
var settings = CvVariantSettingsJson.Deserialize(source.SettingsJson);
return await CreateAsync(ownerUserId, string.IsNullOrWhiteSpace(newName) ? $"{source.Name} (copy)" : newName, source.JobApplicationId, settings, ct);
}
public async Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct)
{
var variant = await GetAsync(ownerUserId, id, ct);
if (variant is null) return false;
_db.CvVariantVersions.RemoveRange(_db.CvVariantVersions.Where(x => x.CvVariantId == id));
_db.CvVariants.Remove(variant);
await _db.SaveChangesAsync(ct);
return true;
}
public async Task<IReadOnlyList<CvVariantVersionInfo>> ListVersionsAsync(string ownerUserId, int id, CancellationToken ct)
{
var variant = await GetAsync(ownerUserId, id, ct);
if (variant is null) return Array.Empty<CvVariantVersionInfo>();
var versions = await _db.CvVariantVersions.Where(x => x.CvVariantId == id)
.OrderByDescending(x => x.Version)
.Select(x => new { x.Version, x.Source, x.CreatedAtUtc }).ToListAsync(ct);
return versions.Select(x => new CvVariantVersionInfo(x.Version, x.Source, x.CreatedAtUtc, x.Version == variant.Version)).ToList();
}
public async Task<CvVariant?> RestoreVersionAsync(string ownerUserId, int id, int version, CancellationToken ct)
{
var variant = await GetAsync(ownerUserId, id, ct);
if (variant is null) return null;
var target = await _db.CvVariantVersions.FirstOrDefaultAsync(x => x.CvVariantId == id && x.Version == version, ct);
if (target is null) return null;
var settings = CvVariantSettingsJson.Deserialize(target.SettingsJson);
return await SaveAsync(ownerUserId, id, null, settings, $"restore:v{version}", ct);
}
public async Task<ThemedCvRenderResult?> RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct)
{
var variant = await GetAsync(ownerUserId, id, ct);
if (variant is null) return null;
var settings = CvVariantSettingsJson.Deserialize(variant.SettingsJson);
var profile = await _career.LoadStructuredAsync(ownerUserId, ct);
return RenderInternal(profile, settings, person);
}
public async Task<ThemedCvRenderResult> RenderSettingsAsync(string ownerUserId, CvVariantSettings settings, CvRenderPerson person, CancellationToken ct)
{
var profile = await _career.LoadStructuredAsync(ownerUserId, ct);
return RenderInternal(profile, settings, person);
}
public async Task<(ThemedCvRenderResult render, string ownerUserId)?> RenderPublicAsync(string publicSlug, CvRenderPerson person, CancellationToken ct)
{
var variant = await _db.CvVariants.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.PublicSlug == publicSlug && x.IsPublic, ct);
if (variant is null) return null;
var settings = CvVariantSettingsJson.Deserialize(variant.SettingsJson);
var profile = await _career.LoadStructuredForOwnerAsync(variant.OwnerUserId, ct);
return (RenderInternal(profile, settings, person), variant.OwnerUserId);
}
public async Task<string?> GetPublicOwnerAsync(string publicSlug, CancellationToken ct)
{
return await _db.CvVariants.IgnoreQueryFilters()
.Where(x => x.PublicSlug == publicSlug && x.IsPublic)
.Select(x => x.OwnerUserId).FirstOrDefaultAsync(ct);
}
private ThemedCvRenderResult RenderInternal(StructuredCvProfile profile, CvVariantSettings settings, CvRenderPerson person)
{
var theme = CvThemeCatalog.Resolve(settings.ThemeId);
var model = CvVariantResolver.Build(profile, settings, person.FallbackName, person.PhotoDataUrl);
return _renderer.Render(model, theme, settings);
}
private void AppendVersion(CvVariant variant, string source) =>
_db.CvVariantVersions.Add(new CvVariantVersion
{
OwnerUserId = variant.OwnerUserId,
CvVariantId = variant.Id,
Version = variant.Version,
SettingsJson = variant.SettingsJson,
Source = source,
CreatedAtUtc = DateTimeOffset.UtcNow,
});
private static CvVariantSummary Summarize(CvVariant v)
{
var settings = CvVariantSettingsJson.Deserialize(v.SettingsJson);
return new CvVariantSummary(v.Id, v.Name, settings.ThemeId, v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc);
}
private static string CleanName(string? name) => string.IsNullOrWhiteSpace(name) ? "Untitled CV" : name.Trim();
private static string NewSlug() => Guid.NewGuid().ToString("N");
}