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:
@@ -30,6 +30,11 @@ public interface ICareerProfileService
|
||||
// and the current state are both preserved; the restore is itself reversible). Returns the
|
||||
// restored profile, or null if the version does not exist.
|
||||
Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken);
|
||||
|
||||
// Read-only structured load for a specific owner, bypassing the tenant query filter. For public
|
||||
// CV rendering (/cv/{slug}) where there is no authenticated current user. Never writes, never
|
||||
// backfills — falls back to the ProfileJson blob if relational rows are absent.
|
||||
Task<StructuredCvProfile> LoadStructuredForOwnerAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record CareerProfileVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
|
||||
@@ -120,6 +125,29 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
|
||||
}
|
||||
|
||||
public async Task<StructuredCvProfile> LoadStructuredForOwnerAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = await _db.CareerProfiles.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (profile is null) return new StructuredCvProfile();
|
||||
|
||||
var experiences = await _db.CareerExperiences.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var education = await _db.CareerEducations.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var skills = await _db.CareerSkills.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var projects = await _db.CareerProjects.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var certifications = await _db.CareerCertifications.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var languages = await _db.CareerLanguages.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
|
||||
// No relational rows yet (a pre-Phase-3 profile that has never been re-saved): fall back to
|
||||
// the blob so a public CV still renders. Read-only, so we don't backfill here.
|
||||
if (experiences.Count == 0 && education.Count == 0 && skills.Count == 0 && projects.Count == 0
|
||||
&& certifications.Count == 0 && languages.Count == 0 && !string.IsNullOrWhiteSpace(profile.ProfileJson))
|
||||
{
|
||||
return StructuredCvProfileJson.Deserialize(profile.ProfileJson);
|
||||
}
|
||||
|
||||
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CareerProfileVersionInfo>> ListVersionsAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record ThemedCvRenderResult(string ThemeId, string SuggestedFileName, string Html);
|
||||
|
||||
public interface IThemedCvRenderer
|
||||
{
|
||||
ThemedCvRenderResult Render(CvRenderModel model, CvTheme theme, CvVariantSettings settings);
|
||||
}
|
||||
|
||||
// ONE render path for every theme. The theme (data) drives layout, palette, typography and spacing;
|
||||
// the variant settings apply a few runtime overrides (accent, fonts, density, page size, photo,
|
||||
// icons, page numbers). Adding a theme never touches this file. docs/architecture/cv-theme-engine.md.
|
||||
public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
{
|
||||
public ThemedCvRenderResult Render(CvRenderModel model, CvTheme theme, CvVariantSettings settings)
|
||||
{
|
||||
settings = CvVariantSettingsJson.Normalize(settings);
|
||||
var accent = Override(settings.AccentColor, theme.Accent);
|
||||
var headingColor = theme.HeadingColor ?? accent;
|
||||
var headingFont = Override(settings.HeadingFont, theme.HeadingFont);
|
||||
var bodyFont = Override(settings.BodyFont, theme.BodyFont);
|
||||
var density = DensityScale(settings.Density);
|
||||
var pageSize = string.Equals(Override(settings.PageSize, "a4"), "letter", StringComparison.OrdinalIgnoreCase) ? "Letter" : "A4";
|
||||
var pageDims = pageSize == "Letter" ? ("215.9mm", "279.4mm") : ("210mm", "297mm");
|
||||
var showIcons = settings.ShowIcons && theme.DefaultIcons;
|
||||
|
||||
var twoColumn = theme.Layout is "sidebar-left" or "sidebar-right";
|
||||
var (sidebarHtml, mainHtml) = twoColumn
|
||||
? SplitColumns(model, theme, showIcons)
|
||||
: (string.Empty, RenderSections(model.Sections, theme));
|
||||
|
||||
var css = BuildCss(theme, accent, headingColor, headingFont, bodyFont, density, pageDims, twoColumn);
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn);
|
||||
var body = theme.Layout switch
|
||||
{
|
||||
"sidebar-left" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons)}<section class=""main"">{mainHtml}</section></div>",
|
||||
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons)}</div>",
|
||||
_ => $@"{header}<section class=""main"">{mainHtml}</section>",
|
||||
};
|
||||
// For two-column themes the header renders inside the sidebar; single/header-band render it on top.
|
||||
var page = twoColumn ? body : body;
|
||||
|
||||
var html = $@"<!DOCTYPE html>
|
||||
<html lang=""{Attr(settings.Language ?? "en")}"">
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>{Enc(model.FullName)} — {Enc(theme.Name)}</title>
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class=""page"">{page}</main>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
var fileName = Slug($"{model.FullName}-{theme.Id}") + ".pdf";
|
||||
return new ThemedCvRenderResult(theme.Id, fileName, html);
|
||||
}
|
||||
|
||||
private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, bool showIcons)
|
||||
{
|
||||
var sidebarKeys = theme.SidebarSections;
|
||||
var sidebar = new StringBuilder();
|
||||
var mainSections = new List<CvRenderSection>();
|
||||
foreach (var section in model.Sections)
|
||||
{
|
||||
var baseKey = section.Key.Contains(':') ? section.Key : section.Key;
|
||||
if (sidebarKeys.Contains(baseKey, StringComparer.OrdinalIgnoreCase))
|
||||
sidebar.Append(RenderSection(section, theme));
|
||||
else
|
||||
mainSections.Add(section);
|
||||
}
|
||||
return (sidebar.ToString(), RenderSections(mainSections, theme));
|
||||
}
|
||||
|
||||
private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons)
|
||||
{
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn: true);
|
||||
var contact = theme.SidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase)
|
||||
? RenderContactBlock(model.Contact, showIcons, sidebar: true)
|
||||
: string.Empty;
|
||||
return $@"<aside class=""sidebar"">{header}{contact}{sectionsHtml}</aside>";
|
||||
}
|
||||
|
||||
private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn)
|
||||
{
|
||||
var photo = RenderPhoto(model.PhotoDataUrl, theme.PhotoShape);
|
||||
var kicker = theme.HeaderStyle == "kicker" ? @"<div class=""kicker"">Curriculum Vitae</div>" : string.Empty;
|
||||
var name = $@"<h1 class=""name"">{Enc(model.FullName)}</h1>";
|
||||
var headline = string.IsNullOrWhiteSpace(model.Headline) ? string.Empty : $@"<div class=""headline"">{Enc(model.Headline)}</div>";
|
||||
var contact = twoColumn ? string.Empty : RenderContactBlock(model.Contact, showIcons, sidebar: false);
|
||||
var headerClass = twoColumn ? "hero" : $"header header-{theme.HeaderStyle}";
|
||||
return $@"<header class=""{headerClass}"">{photo}<div class=""head-text"">{kicker}{name}{headline}{contact}</div></header>";
|
||||
}
|
||||
|
||||
private static string RenderPhoto(string? dataUrl, string shape)
|
||||
{
|
||||
if (shape == "none" || string.IsNullOrWhiteSpace(dataUrl)) return string.Empty;
|
||||
return $@"<div class=""photo photo-{shape}""><img src=""{Attr(dataUrl)}"" alt=""Profile photo"" /></div>";
|
||||
}
|
||||
|
||||
private static string RenderContactBlock(List<CvContactItem> contact, bool showIcons, bool sidebar)
|
||||
{
|
||||
if (contact.Count == 0) return string.Empty;
|
||||
var items = new StringBuilder();
|
||||
foreach (var c in contact)
|
||||
{
|
||||
var icon = showIcons ? Icon(c.Icon) : string.Empty;
|
||||
var text = c.Href is null ? Enc(c.Value) : $@"<a href=""{Attr(c.Href)}"">{Enc(c.Value)}</a>";
|
||||
items.Append($@"<span class=""contact-item"">{icon}{text}</span>");
|
||||
}
|
||||
return $@"<div class=""contact {(sidebar ? "contact-stacked" : "contact-inline")}"">{items}</div>";
|
||||
}
|
||||
|
||||
private static string RenderSections(IEnumerable<CvRenderSection> sections, CvTheme theme)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var section in sections) sb.Append(RenderSection(section, theme));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderSection(CvRenderSection section, CvTheme theme)
|
||||
{
|
||||
if (section.IsEmpty) return string.Empty;
|
||||
var inner = section.Kind switch
|
||||
{
|
||||
"bullets" => $@"<ul class=""bullets"">{Items(section.Bullets)}</ul>",
|
||||
"tags" => $@"<ul class=""tags"">{string.Join("", section.Tags.Select(t => $@"<li class=""tag"">{Enc(t)}</li>"))}</ul>",
|
||||
_ => string.Join("", section.Entries.Select(RenderEntry)),
|
||||
};
|
||||
return $@"<section class=""section""><h2 class=""section-title"">{Enc(section.Title)}</h2>{inner}</section>";
|
||||
}
|
||||
|
||||
private static string RenderEntry(CvRenderEntry entry)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(@"<article class=""entry"">");
|
||||
var hasMeta = !string.IsNullOrWhiteSpace(entry.Meta);
|
||||
sb.Append(@"<div class=""entry-head"">");
|
||||
sb.Append($@"<div class=""entry-title"">{Enc(entry.Title)}</div>");
|
||||
if (hasMeta) sb.Append($@"<div class=""entry-meta"">{Enc(entry.Meta)}</div>");
|
||||
sb.Append("</div>");
|
||||
if (!string.IsNullOrWhiteSpace(entry.Subtitle)) sb.Append($@"<div class=""entry-subtitle"">{Enc(entry.Subtitle)}</div>");
|
||||
if (entry.Bullets.Count > 0) sb.Append($@"<ul class=""bullets"">{Items(entry.Bullets)}</ul>");
|
||||
if (entry.Tags.Count > 0) sb.Append($@"<ul class=""tags entry-tags"">{string.Join("", entry.Tags.Select(t => $@"<li class=""tag"">{Enc(t)}</li>"))}</ul>");
|
||||
sb.Append("</article>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Items(IEnumerable<string> items) =>
|
||||
string.Join("", items.Select(i => $"<li>{Enc(i)}</li>"));
|
||||
|
||||
private static string BuildCss(CvTheme t, string accent, string headingColor, string headingFont, string bodyFont, double density, (string w, string h) page, bool twoColumn)
|
||||
{
|
||||
var margin = F(t.PageMarginMm * density);
|
||||
var sectionGap = F(t.SectionGapMm * density);
|
||||
var entryGap = F(t.EntryGapMm * density);
|
||||
var headingCss = t.HeadingStyle switch
|
||||
{
|
||||
"underline" => $".section-title{{border-bottom:1.5px solid {t.Line};padding-bottom:1.5mm;}}",
|
||||
"plain" => ".section-title{letter-spacing:.01em;}",
|
||||
"bar" => $".section-title{{padding-left:2.5mm;border-left:3px solid {accent};}}",
|
||||
_ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(t.HeadingSizePt * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}",
|
||||
};
|
||||
var layoutCss = twoColumn
|
||||
? $@".cols{{display:grid;grid-template-columns:{(t.Layout == "sidebar-right" ? $"1fr {F(t.SidebarWidthMm)}mm" : $"{F(t.SidebarWidthMm)}mm 1fr")};min-height:{page.h};}}
|
||||
.sidebar{{background:{t.SidebarBg};color:{t.SidebarInk};padding:{margin}mm;}}
|
||||
.sidebar .section-title{{color:{t.SidebarInk};border-color:rgba(255,255,255,.35);}}
|
||||
.sidebar .tag{{border-color:rgba(255,255,255,.4);}}
|
||||
.sidebar a{{color:inherit;}}
|
||||
.main{{padding:{margin}mm;}}
|
||||
.hero .name{{color:{t.SidebarInk};}}"
|
||||
: $@".main{{padding:0 {margin}mm {margin}mm {margin}mm;}}
|
||||
.header{{padding:{margin}mm {margin}mm {F(t.SectionGapMm * density)}mm {margin}mm;display:flex;gap:6mm;align-items:center;}}
|
||||
.header-band{{background:{accent};color:#fff;}}
|
||||
.header-band .name,.header-band .headline,.header-band a{{color:#fff;}}
|
||||
.header-centered{{flex-direction:column;text-align:center;justify-content:center;}}
|
||||
.header-centered .contact{{justify-content:center;}}
|
||||
.header-plain{{border-bottom:2px solid {accent};}}";
|
||||
|
||||
return $@"
|
||||
*{{box-sizing:border-box;}}
|
||||
body{{margin:0;background:#e9edf2;color:{t.Ink};font-family:{bodyFont};font-size:{F(t.BodySizePt)}pt;line-height:{F(t.LineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}}
|
||||
.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{t.Paper};overflow:hidden;}}
|
||||
h1,h2{{font-family:{headingFont};}}
|
||||
.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{t.Ink};line-height:1.1;}}
|
||||
.kicker{{text-transform:uppercase;letter-spacing:.3em;font-size:7.5pt;color:{accent};margin-bottom:1.5mm;}}
|
||||
.headline{{margin-top:1.5mm;color:{t.Muted};font-size:{F(t.BodySizePt + 0.5)}pt;}}
|
||||
.head-text{{flex:1;}}
|
||||
.photo{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid {t.Line};}}
|
||||
.photo-square{{border-radius:2mm;}}
|
||||
.photo-rounded{{border-radius:5mm;}}
|
||||
.photo-circle{{border-radius:50%;}}
|
||||
.photo img{{width:100%;height:100%;object-fit:cover;display:block;}}
|
||||
.contact{{display:flex;gap:3mm;flex-wrap:wrap;color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;margin-top:2.5mm;}}
|
||||
.contact-stacked{{flex-direction:column;gap:1.8mm;}}
|
||||
.contact-item{{display:inline-flex;align-items:center;gap:1.2mm;}}
|
||||
.contact a{{color:inherit;text-decoration:none;}}
|
||||
.contact svg{{width:3.2mm;height:3.2mm;flex:0 0 auto;opacity:.85;}}
|
||||
.hero{{margin-bottom:{sectionGap}mm;}}
|
||||
.hero .name{{font-size:{F(t.NameSizePt - 3)}pt;}}
|
||||
.section{{margin-top:{sectionGap}mm;}}
|
||||
.section:first-child{{margin-top:0;}}
|
||||
.section-title{{margin:0 0 {F(2.6 * density)}mm 0;font-size:{F(t.HeadingSizePt)}pt;font-weight:700;color:{headingColor};}}
|
||||
{headingCss}
|
||||
.bullets{{margin:0;padding-left:4.5mm;}}
|
||||
.bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}}
|
||||
.tags{{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:1.8mm;}}
|
||||
.tag{{border:1px solid {t.Line};border-radius:999px;padding:.7mm 2.2mm;font-size:{F(t.BodySizePt - 0.5)}pt;}}
|
||||
.entry{{margin-bottom:{entryGap}mm;}}
|
||||
.entry:last-child{{margin-bottom:0;}}
|
||||
.entry-head{{display:flex;justify-content:space-between;gap:4mm;align-items:baseline;}}
|
||||
.entry-title{{font-weight:700;font-size:{F(t.BodySizePt + 1)}pt;}}
|
||||
.entry-meta{{color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;white-space:nowrap;}}
|
||||
.entry-subtitle{{color:{t.Muted};font-size:{F(t.BodySizePt)}pt;margin:.4mm 0 1.2mm 0;}}
|
||||
.entry-tags{{margin-top:1.4mm;}}
|
||||
{layoutCss}
|
||||
@page{{size:{page.w} {page.h};margin:0;}}
|
||||
";
|
||||
}
|
||||
|
||||
private static double DensityScale(string? density) => (density ?? "balanced").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"compact" => 0.82,
|
||||
"roomy" => 1.18,
|
||||
_ => 1.0,
|
||||
};
|
||||
|
||||
private static string Icon(string kind) => kind switch
|
||||
{
|
||||
"email" => Svg("M2 4h12v8H2z M2 4l6 4 6-4"),
|
||||
"phone" => Svg("M3 3c0 5 5 10 10 10l1-3-3-1-1 1c-2-1-4-3-5-5l1-1-1-3z"),
|
||||
"location" => Svg("M8 1a4 4 0 0 0-4 4c0 3 4 8 4 8s4-5 4-8a4 4 0 0 0-4-4z M8 5v.01"),
|
||||
"web" => Svg("M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1z M1 8h14 M8 1c2 2 2 12 0 14 M8 1c-2 2-2 12 0 14"),
|
||||
"linkedin" => Svg("M3 6v7 M3 3v.01 M7 13V6 M7 9c0-3 5-3 5 0v4"),
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private static string Svg(string path) =>
|
||||
$@"<svg viewBox=""0 0 16 16"" fill=""none"" stroke=""currentColor"" stroke-width=""1.3"" stroke-linecap=""round"" stroke-linejoin=""round""><path d=""{path}""/></svg>";
|
||||
|
||||
private static string Override(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
private static string F(double v) => v.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
private static string Enc(string? v) => WebUtility.HtmlEncode(v ?? string.Empty);
|
||||
private static string Attr(string? v) => WebUtility.HtmlEncode(v ?? string.Empty).Replace("'", "'", StringComparison.Ordinal);
|
||||
|
||||
private static string Slug(string v)
|
||||
{
|
||||
var cleaned = new string((v ?? string.Empty).ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
|
||||
while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal);
|
||||
return cleaned.Trim('-');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user