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
+259
View File
@@ -0,0 +1,259 @@
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
// The resolved, presentation-ready shape a theme renders. Built by CvVariantResolver from the master
// StructuredCvProfile + a variant's CvVariantSettings. This is a projection — it owns no data.
public sealed class CvRenderModel
{
public string FullName { get; set; } = string.Empty;
public string? Headline { get; set; }
public string? PhotoDataUrl { get; set; }
public List<CvContactItem> Contact { get; set; } = new();
public List<CvRenderSection> Sections { get; set; } = new();
}
public sealed class CvContactItem
{
public string Icon { get; set; } = string.Empty; // "email" | "phone" | "location" | "web" | "linkedin"
public string Value { get; set; } = string.Empty;
public string? Href { get; set; }
}
public sealed class CvRenderSection
{
public string Key { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
// bullets | tags | entries
public string Kind { get; set; } = "entries";
public List<string> Bullets { get; set; } = new();
public List<string> Tags { get; set; } = new();
public List<CvRenderEntry> Entries { get; set; } = new();
public bool IsEmpty => Bullets.Count == 0 && Tags.Count == 0 && Entries.Count == 0;
}
public sealed class CvRenderEntry
{
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? Meta { get; set; }
public List<string> Bullets { get; set; } = new();
public List<string> Tags { get; set; } = new();
}
// Turns the master profile + a variant lens into a CvRenderModel. All section order/visibility and
// per-item overrides are applied here; the renderer only draws. Overrides are keyed by ItemKey so
// the master profile is referenced, never copied.
public static class CvVariantResolver
{
private static readonly string[] DefaultOrder =
{
"summary", "experience", "education", "projects", "skills", "certifications", "languages", "interests",
};
public static CvRenderModel Build(StructuredCvProfile profile, CvVariantSettings settings, string fallbackName, string? photoDataUrl)
{
settings = CvVariantSettingsJson.Normalize(settings);
var model = new CvRenderModel
{
FullName = Trim(profile.Contact.FullName) ?? fallbackName,
Headline = Trim(settings.Headline) ?? Trim(profile.Contact.Headline),
PhotoDataUrl = settings.ShowPhoto ? Trim(photoDataUrl) : null,
};
AddContact(model.Contact, "email", profile.Contact.Email, v => $"mailto:{v}");
AddContact(model.Contact, "phone", profile.Contact.Phone, null);
AddContact(model.Contact, "location", profile.Contact.Location, null);
AddContact(model.Contact, "web", profile.Contact.Website, AsUrl);
AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, AsUrl);
var built = new Dictionary<string, CvRenderSection>(StringComparer.OrdinalIgnoreCase)
{
["summary"] = BulletSection("summary", "Professional Summary", profile.Summary),
["skills"] = TagSection("skills", "Skills", profile.Skills),
["experience"] = ExperienceSection(profile, settings),
["education"] = EducationSection(profile, settings),
["projects"] = ProjectSection(profile, settings),
["certifications"] = CertificationSection(profile, settings),
["languages"] = LanguageSection(profile),
["interests"] = TagSection("interests", "Interests", profile.Interests),
};
// OtherSections from the master profile become body sections keyed other:<n>.
for (var i = 0; i < profile.OtherSections.Count; i++)
{
var other = profile.OtherSections[i];
var key = $"other:{i}";
built[key] = new CvRenderSection { Key = key, Title = Trim(other.Title) ?? "Additional", Kind = "bullets", Bullets = Clean(other.Items) };
}
// Determine order + visibility from settings, falling back to the default order then any extras.
var settingByKey = settings.Sections.ToDictionary(s => s.Key, s => s, StringComparer.OrdinalIgnoreCase);
var ordered = settings.Sections.Count > 0
? settings.Sections.Select(s => s.Key).ToList()
: DefaultOrder.ToList();
foreach (var key in built.Keys)
{
if (!ordered.Contains(key, StringComparer.OrdinalIgnoreCase)) ordered.Add(key);
}
foreach (var key in ordered)
{
if (key.StartsWith("custom:", StringComparison.OrdinalIgnoreCase)) continue; // handled below
if (!built.TryGetValue(key, out var section)) continue;
if (settingByKey.TryGetValue(key, out var cfg))
{
if (cfg.Hidden) continue;
if (!string.IsNullOrWhiteSpace(cfg.Title)) section.Title = cfg.Title!.Trim();
}
if (!section.IsEmpty) model.Sections.Add(section);
}
// Variant-only custom sections, placed by their position in the order list if present.
foreach (var custom in settings.CustomSections)
{
if (custom.Hidden) continue;
var items = Clean(custom.Items);
if (items.Count == 0) continue;
model.Sections.Add(new CvRenderSection
{
Key = $"custom:{custom.Key}",
Title = Trim(custom.Title) ?? "Additional",
Kind = "bullets",
Bullets = items,
});
}
return model;
}
private static CvRenderSection ExperienceSection(StructuredCvProfile profile, CvVariantSettings settings)
{
var section = new CvRenderSection { Key = "experience", Title = "Professional Experience", Kind = "entries" };
foreach (var job in profile.Jobs)
{
var ov = OverrideFor(settings, job.Id);
if (ov?.Hidden == true) continue;
section.Entries.Add(new CvRenderEntry
{
Title = Trim(ov?.Title) ?? Trim(job.Title),
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(job.Company, job.Location),
Meta = DateRange(job.Start, job.End, job.IsCurrent),
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(job.Bullets),
Tags = Clean(job.Skills),
});
}
return section;
}
private static CvRenderSection EducationSection(StructuredCvProfile profile, CvVariantSettings settings)
{
var section = new CvRenderSection { Key = "education", Title = "Education", Kind = "entries" };
foreach (var ed in profile.Education)
{
var ov = OverrideFor(settings, ed.Id);
if (ov?.Hidden == true) continue;
var title = string.IsNullOrWhiteSpace(ed.QualificationLevel) ? Trim(ed.Qualification) : $"{ed.Qualification} ({ed.QualificationLevel})";
section.Entries.Add(new CvRenderEntry
{
Title = Trim(ov?.Title) ?? title,
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(ed.Institution, ed.Location),
Meta = DateRange(ed.Start, ed.End, false),
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(ed.Details),
});
}
return section;
}
private static CvRenderSection ProjectSection(StructuredCvProfile profile, CvVariantSettings settings)
{
var section = new CvRenderSection { Key = "projects", Title = "Projects", Kind = "entries" };
foreach (var pr in profile.Projects)
{
var ov = OverrideFor(settings, pr.Id);
if (ov?.Hidden == true) continue;
section.Entries.Add(new CvRenderEntry
{
Title = Trim(ov?.Title) ?? Trim(pr.Name),
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(pr.Role, pr.Location),
Meta = DateRange(pr.Start, pr.End, false),
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(pr.Bullets),
Tags = Clean(pr.Skills),
});
}
return section;
}
private static CvRenderSection CertificationSection(StructuredCvProfile profile, CvVariantSettings settings)
{
var section = new CvRenderSection { Key = "certifications", Title = "Certifications", Kind = "entries" };
foreach (var c in profile.Certifications)
{
var ov = OverrideFor(settings, c.Id);
if (ov?.Hidden == true) continue;
section.Entries.Add(new CvRenderEntry
{
Title = Trim(ov?.Title) ?? Trim(c.Name),
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(c.Issuer, c.Location),
Meta = Trim(c.Date),
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(c.Details),
});
}
return section;
}
private static CvRenderSection LanguageSection(StructuredCvProfile profile)
{
var section = new CvRenderSection { Key = "languages", Title = "Languages", Kind = "tags" };
foreach (var l in profile.Languages)
{
var name = Trim(l.Name);
if (name is null) continue;
var level = Trim(l.Level);
section.Tags.Add(level is null ? name : $"{name} — {level}");
}
return section;
}
private static CvItemOverride? OverrideFor(CvVariantSettings settings, string? itemKey)
{
if (string.IsNullOrWhiteSpace(itemKey)) return null;
return settings.Overrides.TryGetValue(itemKey, out var ov) ? ov : null;
}
private static CvRenderSection BulletSection(string key, string title, IEnumerable<string> items) =>
new() { Key = key, Title = title, Kind = "bullets", Bullets = Clean(items) };
private static CvRenderSection TagSection(string key, string title, IEnumerable<string> items) =>
new() { Key = key, Title = title, Kind = "tags", Tags = Clean(items) };
private static void AddContact(List<CvContactItem> list, string icon, string? value, Func<string, string>? href)
{
var v = Trim(value);
if (v is null) return;
list.Add(new CvContactItem { Icon = icon, Value = v, Href = href?.Invoke(v) });
}
private static string AsUrl(string v) => v.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? v : $"https://{v}";
private static string? JoinDot(params string?[] parts)
{
var joined = string.Join(" · ", parts.Select(Trim).Where(x => x != null));
return string.IsNullOrWhiteSpace(joined) ? null : joined;
}
private static string? DateRange(string? start, string? end, bool isCurrent)
{
var s = Trim(start);
var e = Trim(end);
if (s is null && e is null) return null;
if (s is null) return e;
return $"{s} {(isCurrent ? "Present" : e ?? "Present")}";
}
private static List<string> Clean(IEnumerable<string>? items) =>
(items ?? Array.Empty<string>()).Select(x => x?.Trim() ?? string.Empty).Where(x => x.Length > 0).ToList();
private static string? Trim(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
}