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>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user