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
+145
View File
@@ -0,0 +1,145 @@
namespace JobTrackerApi.Models;
// A theme is DATA, not code. The renderer (ThemedCvRenderer) has one render path; every visual
// difference between themes is expressed by the fields below. Adding a theme = adding one CvTheme to
// CvThemeCatalog — no renderer changes. This is the core Phase 4 architectural rule
// ("Do NOT hardcode HTML templates"). docs/architecture/cv-theme-engine.md.
public sealed class CvTheme
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Category { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
// single | sidebar-left | sidebar-right | header-band
public string Layout { get; init; } = "single";
public double SidebarWidthMm { get; init; } = 62;
// Palette
public string Accent { get; init; } = "#2563eb";
public string Ink { get; init; } = "#111827";
public string Muted { get; init; } = "#4b5563";
public string Line { get; init; } = "#d1d5db";
public string Paper { get; init; } = "#ffffff";
public string SidebarBg { get; init; } = "#f3f4f6";
public string SidebarInk { get; init; } = "#111827";
public string? HeadingColor { get; init; } // null => accent
// Typography
public string HeadingFont { get; init; } = "Georgia, 'Times New Roman', serif";
public string BodyFont { get; init; } = "Arial, Helvetica, sans-serif";
public double NameSizePt { get; init; } = 24;
public double HeadingSizePt { get; init; } = 12;
public double BodySizePt { get; init; } = 10;
public double LineHeight { get; init; } = 1.42;
// Spacing (mm)
public double PageMarginMm { get; init; } = 16;
public double SectionGapMm { get; init; } = 6;
public double EntryGapMm { get; init; } = 4.5;
// plain | band | centered | kicker
public string HeaderStyle { get; init; } = "plain";
// caps-rule | underline | plain | bar
public string HeadingStyle { get; init; } = "caps-rule";
// none | square | rounded | circle
public string PhotoShape { get; init; } = "rounded";
public bool DefaultIcons { get; init; }
// Which section keys render in the sidebar for two-column layouts (ignored for single/header-band).
public List<string> SidebarSections { get; init; } = new() { "contact", "skills", "languages" };
}
public static class CvThemeCatalog
{
// 8 professional themes, all data. To add one: append here.
public static readonly IReadOnlyList<CvTheme> Themes = new List<CvTheme>
{
new()
{
Id = "modern", Name = "Modern", Category = "Modern Professional",
Description = "Clean SaaS-style layout with a confident accent header.",
Layout = "header-band", HeaderStyle = "band", HeadingStyle = "caps-rule",
Accent = "#2563eb", HeadingFont = "'Segoe UI', Roboto, Arial, sans-serif", BodyFont = "'Segoe UI', Roboto, Arial, sans-serif",
PhotoShape = "rounded", DefaultIcons = true,
},
new()
{
Id = "minimal", Name = "Minimal", Category = "Minimal",
Description = "Quiet, spacious, typography-first. Nothing competes with the content.",
Layout = "single", HeaderStyle = "plain", HeadingStyle = "plain",
Accent = "#111827", HeadingColor = "#111827", Muted = "#6b7280",
HeadingFont = "'Helvetica Neue', Arial, sans-serif", BodyFont = "'Helvetica Neue', Arial, sans-serif",
SectionGapMm = 7, EntryGapMm = 5, PhotoShape = "none",
},
new()
{
Id = "executive", Name = "Executive", Category = "Executive",
Description = "High-contrast, serif, leadership-weighted. For senior and client-facing roles.",
Layout = "single", HeaderStyle = "centered", HeadingStyle = "underline",
Accent = "#7c2d12", Ink = "#1c1917", Muted = "#57534e", Line = "#1c1917",
HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "Georgia, 'Times New Roman', serif",
NameSizePt = 27, PhotoShape = "square",
},
new()
{
Id = "technical", Name = "Technical", Category = "Technical",
Description = "Dense two-column layout tuned for engineering CVs — skills and projects up front.",
Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar",
Accent = "#0f4c5c", Ink = "#102a43", Muted = "#486581", SidebarBg = "#0f4c5c", SidebarInk = "#ffffff",
HeadingFont = "'Roboto', Arial, sans-serif", BodyFont = "'Roboto', Arial, sans-serif",
BodySizePt = 9.5, LineHeight = 1.36, DefaultIcons = true,
SidebarSections = new() { "contact", "skills", "languages", "certifications" },
},
new()
{
Id = "ats-classic", Name = "ATS Classic", Category = "ATS Professional",
Description = "Single column, no graphics, maximum parser compatibility. The safe default.",
Layout = "single", HeaderStyle = "plain", HeadingStyle = "caps-rule",
Accent = "#334155", Ink = "#111827", Muted = "#374151",
HeadingFont = "Arial, Helvetica, sans-serif", BodyFont = "Arial, Helvetica, sans-serif",
PhotoShape = "none",
},
new()
{
Id = "nordic", Name = "Nordic", Category = "Modern Professional",
Description = "Calm cool-blue sidebar, generous whitespace, Scandinavian restraint.",
Layout = "sidebar-right", HeaderStyle = "plain", HeadingStyle = "caps-rule",
Accent = "#3b6ea5", Ink = "#1f2937", Muted = "#4b5563", SidebarBg = "#eef3f8", SidebarInk = "#1f2937",
HeadingFont = "'Segoe UI', Arial, sans-serif", BodyFont = "'Segoe UI', Arial, sans-serif",
PhotoShape = "circle", DefaultIcons = true,
},
new()
{
Id = "elegant", Name = "Elegant", Category = "Creative",
Description = "Editorial serif headings over sans body, premium spacing and a plum accent.",
Layout = "single", HeaderStyle = "kicker", HeadingStyle = "underline",
Accent = "#7c3aed", Ink = "#1f2937", Muted = "#4b5563",
HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "'Segoe UI', Arial, sans-serif",
NameSizePt = 26, SectionGapMm = 7, PhotoShape = "circle",
},
new()
{
Id = "creative", Name = "Creative", Category = "Creative",
Description = "Bold accent sidebar and photo-forward header for design and product roles.",
Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar",
Accent = "#db2777", Ink = "#18181b", Muted = "#52525b", SidebarBg = "#db2777", SidebarInk = "#ffffff",
HeadingFont = "'Poppins', 'Segoe UI', Arial, sans-serif", BodyFont = "'Segoe UI', Arial, sans-serif",
PhotoShape = "circle", DefaultIcons = true,
SidebarSections = new() { "contact", "skills", "languages", "interests" },
},
};
public static CvTheme Resolve(string? id)
{
var key = (id ?? string.Empty).Trim().ToLowerInvariant();
return Themes.FirstOrDefault(t => t.Id == key) ?? Themes[0];
}
public static bool Exists(string? id)
{
var key = (id ?? string.Empty).Trim().ToLowerInvariant();
return Themes.Any(t => t.Id == key);
}
}
+53
View File
@@ -0,0 +1,53 @@
namespace JobTrackerApi.Models;
// Phase 4 — Career Workspace Builder.
//
// A CvVariant is NOT another career profile. It is a *lens* over the master CareerProfile: which
// sections show, in what order, with what per-item overrides, rendered through a chosen theme with
// layout/colour/font settings. The career data is never duplicated here — the variant stores only
// selections + overrides keyed by the master profile's stable ItemKeys, plus builder preferences.
// docs/architecture/cv-builder.md.
public sealed class CvVariant
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
// Public share slug for /cv/{slug}. Assigned at creation, stable, unguessable.
public string PublicSlug { get; set; } = string.Empty;
public string Name { get; set; } = "Untitled CV";
// Optional link to the job application this variant was tailored for. Reference only — deleting
// the application must not delete the variant (SetNull), and the variant never owns job data.
public int? JobApplicationId { get; set; }
public JobApplication? JobApplication { get; set; }
// Serialized CvVariantSettings — the whole lens (theme, layout, section order/visibility,
// per-item overrides, builder prefs). One blob because it is edited and saved as a unit and
// never queried field-by-field, exactly like a version snapshot.
public string SettingsJson { get; set; } = string.Empty;
// Public visibility. Off by default; a public variant is served read-only at /cv/{PublicSlug}
// and is marked noindex.
public bool IsPublic { get; set; }
// Autosave version counter (mirrors CareerProfile.Version). Incremented on each saved snapshot.
public int Version { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
}
// Append-only autosave history for a variant, so builder edits are reversible. Mirrors
// CareerProfileVersion. Restore re-saves an old snapshot as a new version (non-destructive).
public sealed class CvVariantVersion
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public int CvVariantId { get; set; }
public CvVariant? CvVariant { get; set; }
public int Version { get; set; }
public string SettingsJson { get; set; } = string.Empty;
public string Source { get; set; } = "autosave"; // autosave | manual | restore | create
public DateTimeOffset CreatedAtUtc { get; set; }
}
+87
View File
@@ -0,0 +1,87 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace JobTrackerApi.Models;
// The "lens" a CvVariant stores. Deliberately all-optional + defaulted so an empty settings object
// renders a sensible default CV straight from the master profile. Overrides are keyed by the master
// profile's stable ItemKeys so career data is referenced, never copied.
public sealed class CvVariantSettings
{
public string ThemeId { get; set; } = "modern";
// Theme overrides (null => use the theme's own value).
public string? AccentColor { get; set; }
public string? HeadingFont { get; set; }
public string? BodyFont { get; set; }
public string? Density { get; set; } // compact | balanced | roomy
public string? PageSize { get; set; } // a4 | letter
public string? DateFormat { get; set; } // display hint for the frontend; renderer uses source strings
public string? Language { get; set; }
public string? Headline { get; set; } // override the contact headline for this variant
public bool ShowPhoto { get; set; }
public bool ShowPageNumbers { get; set; }
public bool ShowIcons { get; set; } = true;
// Section order + visibility + optional renamed heading. Empty => default order, all visible.
public List<CvSectionSetting> Sections { get; set; } = new();
// Per-item overrides keyed by ItemKey (job/education/project/certification stable id).
public Dictionary<string, CvItemOverride> Overrides { get; set; } = new();
// Extra sections unique to this variant (not in the master profile).
public List<CvCustomSectionSetting> CustomSections { get; set; } = new();
}
public sealed class CvSectionSetting
{
public string Key { get; set; } = string.Empty; // summary|skills|experience|education|projects|certifications|languages|interests|links|custom:<k>
public bool Hidden { get; set; }
public string? Title { get; set; } // renamed heading
}
public sealed class CvItemOverride
{
public bool Hidden { get; set; }
public string? Title { get; set; }
public string? Subtitle { get; set; }
public List<string>? Bullets { get; set; } // null => use master bullets; non-null => replace for this variant only
}
public sealed class CvCustomSectionSetting
{
public string Key { get; set; } = string.Empty;
public string? Title { get; set; }
public List<string> Items { get; set; } = new();
public bool Hidden { get; set; }
}
public static class CvVariantSettingsJson
{
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public static string Serialize(CvVariantSettings? settings) =>
JsonSerializer.Serialize(Normalize(settings), Options);
public static CvVariantSettings Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new CvVariantSettings();
try { return Normalize(JsonSerializer.Deserialize<CvVariantSettings>(json, Options)); }
catch { return new CvVariantSettings(); }
}
public static CvVariantSettings Normalize(CvVariantSettings? s)
{
s ??= new CvVariantSettings();
s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant();
s.Sections ??= new();
s.Overrides ??= new();
s.CustomSections ??= new();
return s;
}
}