Files
jobtrackingapp/JobTrackerApi/Services/CvRenderModel.cs
T
cesnimda a3e18e4b44
CI and Deploy / test (push) Failing after 1m51s
CI and Deploy / deploy (push) Has been skipped
feat(career): CV builder backend — data-driven theme engine + variant model
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>
2026-07-18 09:52:58 +02:00

260 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}