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
+29
View File
@@ -42,6 +42,8 @@ namespace JobTrackerApi.Data
public DbSet<CareerLanguage> CareerLanguages => Set<CareerLanguage>();
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
public DbSet<CvVariant> CvVariants => Set<CvVariant>();
public DbSet<CvVariantVersion> CvVariantVersions => Set<CvVariantVersion>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -288,6 +290,33 @@ namespace JobTrackerApi.Data
.WithMany()
.HasForeignKey(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
// Phase 4: CV variants (a lens over the master profile) + their autosave history.
// docs/architecture/cv-builder.md. Same deny-on-null tenant filter as everything else. A
// variant references a job application (reference-not-ownership, SetNull on delete) and never
// owns career data. PublicSlug is globally unique so /cv/{slug} can resolve it anonymously.
modelBuilder.Entity<CvVariant>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CvVariant>()
.HasIndex(x => x.PublicSlug)
.IsUnique();
modelBuilder.Entity<CvVariant>()
.HasIndex(x => new { x.OwnerUserId, x.UpdatedAtUtc });
modelBuilder.Entity<CvVariant>()
.HasOne(x => x.JobApplication)
.WithMany()
.HasForeignKey(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<CvVariantVersion>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CvVariantVersion>()
.HasIndex(x => new { x.CvVariantId, x.Version });
modelBuilder.Entity<CvVariantVersion>()
.HasOne(x => x.CvVariant)
.WithMany()
.HasForeignKey(x => x.CvVariantId)
.OnDelete(DeleteBehavior.Cascade);
}
// Common config for CareerProfile's relational children. The 1:many FK + cascade delete is
+243
View File
@@ -0,0 +1,243 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class CvBuilderTests
{
private static StructuredCvProfile Rich() => new()
{
Contact = { FullName = "Ada Lovelace", Headline = "Engineer", Email = "ada@example.com", Location = "Oslo" },
Summary = { "Builder of engines." },
Jobs =
{
new StructuredCvJob { Id = "job1", Title = "Senior Eng", Company = "Acme", Location = "Oslo", Start = "Jan 2020", End = "Present", IsCurrent = true, Bullets = { "Built X" }, Skills = { "C#" } },
new StructuredCvJob { Id = "job2", Title = "Eng", Company = "Beta", Start = "2018", End = "2019", Bullets = { "Built Y" } },
},
Education = { new StructuredCvEducation { Id = "ed1", Qualification = "BSc", Institution = "Uni" } },
Skills = { "C#", "SQL" },
Projects = { new StructuredCvProject { Id = "pr1", Name = "Proj", Role = "Lead", Skills = { "React" } } },
Certifications = { new StructuredCvCertification { Id = "c1", Name = "AZ-204", Issuer = "MS" } },
Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" } },
};
// ---- Resolver (the lens over the master profile) ----
[Fact]
public void Resolver_defaults_include_every_populated_section()
{
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "Fallback", null);
Assert.Equal("Ada Lovelace", model.FullName);
var keys = model.Sections.Select(s => s.Key).ToList();
Assert.Contains("summary", keys);
Assert.Contains("experience", keys);
Assert.Contains("skills", keys);
Assert.Contains("languages", keys);
}
[Fact]
public void Resolver_hides_a_section_when_configured()
{
var settings = new CvVariantSettings { Sections = { new CvSectionSetting { Key = "skills", Hidden = true } } };
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
Assert.DoesNotContain("skills", model.Sections.Select(s => s.Key));
// Other sections still render (appended after the explicit order).
Assert.Contains("experience", model.Sections.Select(s => s.Key));
}
[Fact]
public void Resolver_respects_explicit_section_order()
{
var settings = new CvVariantSettings
{
Sections = { new CvSectionSetting { Key = "skills" }, new CvSectionSetting { Key = "summary" } },
};
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
var keys = model.Sections.Select(s => s.Key).ToList();
Assert.True(keys.IndexOf("skills") < keys.IndexOf("summary"));
}
[Fact]
public void Resolver_applies_item_override_bullets_without_touching_the_master()
{
var profile = Rich();
var settings = new CvVariantSettings();
settings.Overrides["job1"] = new CvItemOverride { Bullets = new() { "Tailored bullet" } };
var model = CvVariantResolver.Build(profile, settings, "F", null);
var exp = model.Sections.First(s => s.Key == "experience");
Assert.Equal(new[] { "Tailored bullet" }, exp.Entries[0].Bullets);
// Master profile is untouched — the override is a lens, not a write.
Assert.Equal(new[] { "Built X" }, profile.Jobs[0].Bullets);
}
[Fact]
public void Resolver_hides_a_single_item_by_key()
{
var settings = new CvVariantSettings();
settings.Overrides["job2"] = new CvItemOverride { Hidden = true };
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
var exp = model.Sections.First(s => s.Key == "experience");
Assert.Single(exp.Entries);
Assert.Equal("Senior Eng", exp.Entries[0].Title);
}
[Fact]
public void Resolver_adds_variant_only_custom_sections()
{
var settings = new CvVariantSettings
{
CustomSections = { new CvCustomSectionSetting { Key = "vol", Title = "Volunteering", Items = { "Coached juniors" } } },
};
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
Assert.Contains(model.Sections, s => s.Title == "Volunteering" && s.Bullets.Contains("Coached juniors"));
}
// ---- Renderer (one path, every theme is data) ----
[Fact]
public void Every_catalog_theme_renders_valid_html()
{
var renderer = new ThemedCvRenderer();
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings { ShowPhoto = true }, "F", "data:image/png;base64,AAAA");
foreach (var theme in CvThemeCatalog.Themes)
{
var result = renderer.Render(model, theme, new CvVariantSettings { ThemeId = theme.Id });
Assert.Contains("<html", result.Html);
Assert.Contains("Ada Lovelace", result.Html);
Assert.Contains("Senior Eng", result.Html);
Assert.Equal(theme.Id, result.ThemeId);
}
}
[Fact]
public void Two_column_theme_emits_a_sidebar_single_does_not()
{
var renderer = new ThemedCvRenderer();
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "F", null);
var technical = renderer.Render(model, CvThemeCatalog.Resolve("technical"), new CvVariantSettings { ThemeId = "technical" });
var minimal = renderer.Render(model, CvThemeCatalog.Resolve("minimal"), new CvVariantSettings { ThemeId = "minimal" });
Assert.Contains("class=\"sidebar\"", technical.Html);
Assert.DoesNotContain("class=\"sidebar\"", minimal.Html);
}
[Fact]
public void Accent_override_reaches_the_css()
{
var renderer = new ThemedCvRenderer();
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "F", null);
var result = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern", AccentColor = "#123456" });
Assert.Contains("#123456", result.Html);
}
// ---- Variant service (CRUD, autosave history, public, render) ----
private static (JobTrackerContext db, CvVariantService svc) NewService(string userId, string? dbName = null)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(dbName ?? Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(s => s.UserId).Returns(userId);
var db = new JobTrackerContext(options, currentUser.Object);
var svc = new CvVariantService(db, new CareerProfileService(db), new ThemedCvRenderer());
return (db, svc);
}
[Fact]
public async Task Create_then_save_increments_version_and_keeps_history()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
var created = await svc.CreateAsync("user-1", "My CV", null, new CvVariantSettings { ThemeId = "modern" }, default);
Assert.Equal(1, created.Version);
var saved = await svc.SaveAsync("user-1", created.Id, "My CV v2", new CvVariantSettings { ThemeId = "nordic" }, "autosave", default);
Assert.Equal(2, saved!.Version);
var versions = await svc.ListVersionsAsync("user-1", created.Id, default);
Assert.Equal(new[] { 2, 1 }, versions.Select(v => v.Version));
Assert.True(versions[0].IsCurrent);
}
[Fact]
public async Task Restore_reapplies_old_settings_as_a_new_version()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
var v = await svc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "modern" }, default);
await svc.SaveAsync("user-1", v.Id, null, new CvVariantSettings { ThemeId = "creative" }, "autosave", default);
var restored = await svc.RestoreVersionAsync("user-1", v.Id, 1, default);
Assert.Equal(3, restored!.Version);
Assert.Equal("modern", CvVariantSettingsJson.Deserialize(restored.SettingsJson).ThemeId);
}
[Fact]
public async Task Duplicate_copies_settings_with_a_fresh_slug()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
var v = await svc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "elegant" }, default);
var copy = await svc.DuplicateAsync("user-1", v.Id, null, default);
Assert.NotEqual(v.PublicSlug, copy!.PublicSlug);
Assert.Equal("elegant", CvVariantSettingsJson.Deserialize(copy.SettingsJson).ThemeId);
}
[Fact]
public async Task Delete_removes_the_variant_and_its_versions()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
var v = await svc.CreateAsync("user-1", "CV", null, null, default);
Assert.True(await svc.DeleteAsync("user-1", v.Id, default));
Assert.Null(await svc.GetAsync("user-1", v.Id, default));
Assert.Empty(await db.CvVariantVersions.IgnoreQueryFilters().Where(x => x.CvVariantId == v.Id).ToListAsync());
}
[Fact]
public async Task Render_pulls_content_from_the_master_profile()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
await new CareerProfileService(db).SaveVersionAsync("user-1", Rich(), "manual", default);
var v = await svc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "modern" }, default);
var render = await svc.RenderAsync("user-1", v.Id, new CvRenderPerson("Fallback", null), default);
Assert.NotNull(render);
Assert.Contains("Ada Lovelace", render!.Html);
Assert.Contains("Senior Eng", render.Html);
}
[Fact]
public async Task Public_render_bypasses_the_tenant_filter()
{
var dbName = Guid.NewGuid().ToString();
var (ownerDb, ownerSvc) = NewService("user-1", dbName);
await new CareerProfileService(ownerDb).SaveVersionAsync("user-1", Rich(), "manual", default);
var v = await ownerSvc.CreateAsync("user-1", "CV", null, new CvVariantSettings { ThemeId = "minimal" }, default);
await ownerSvc.SetPublicAsync("user-1", v.Id, true, default);
await ownerDb.DisposeAsync();
// Anonymous context: no current user, so the tenant filter denies everything by default.
var (anonDb, anonSvc) = NewService(null!, dbName);
await using var _ = anonDb;
var owner = await anonSvc.GetPublicOwnerAsync(v.PublicSlug, default);
Assert.Equal("user-1", owner);
var result = await anonSvc.RenderPublicAsync(v.PublicSlug, new CvRenderPerson("Ada Lovelace", null), default);
Assert.NotNull(result);
Assert.Contains("Ada Lovelace", result!.Value.render.Html);
}
[Fact]
public async Task Private_variant_is_not_served_publicly()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
var v = await svc.CreateAsync("user-1", "CV", null, null, default);
Assert.Null(await svc.GetPublicOwnerAsync(v.PublicSlug, default));
}
}
@@ -0,0 +1,226 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
// Phase 4 — Career Workspace Builder API. Every endpoint reads the master profile through
// ICareerProfileService and renders through the data-driven theme engine; none of them store career
// data. docs/architecture/cv-builder.md.
[ApiController]
[Route("api/cv")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class CvVariantController : ControllerBase
{
private readonly UserManager<ApplicationUser> _users;
private readonly ICvVariantService _variants;
private readonly ICvPdfExporter _pdf;
private readonly ISummarizerService _ai;
public CvVariantController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai)
{
_users = users;
_variants = variants;
_pdf = pdf;
_ai = ai;
}
public sealed record VariantDto(int Id, string Name, CvVariantSettings Settings, bool IsPublic, string PublicSlug, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc);
public sealed record CreateVariantRequest(string? Name, int? JobApplicationId, CvVariantSettings? Settings);
public sealed record SaveVariantRequest(string? Name, CvVariantSettings? Settings, string? Source);
public sealed record PublicToggleRequest(bool IsPublic);
public sealed record DuplicateRequest(string? Name);
public sealed record PreviewRequest(CvVariantSettings? Settings);
public sealed record RenderDto(string ThemeId, string Html, string SuggestedFileName);
public sealed record AiAssistRequest(string? Action, string? Text, string? Context, string? Role, string? Language);
public sealed record AiAssistResult(string Original, string Result);
[HttpGet("themes")]
public ActionResult<IEnumerable<object>> GetThemes()
{
var themes = CvThemeCatalog.Themes.Select(t => new
{
id = t.Id,
name = t.Name,
category = t.Category,
description = t.Description,
layout = t.Layout,
accent = t.Accent,
photoShape = t.PhotoShape,
supportsIcons = t.DefaultIcons,
swatches = new[] { t.Accent, t.SidebarBg, t.Paper },
});
return Ok(themes);
}
[HttpGet("variants")]
public async Task<ActionResult<IEnumerable<CvVariantSummary>>> List(CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
return Ok(await _variants.ListAsync(user.Id, ct));
}
[HttpPost("variants")]
public async Task<ActionResult<VariantDto>> Create([FromBody] CreateVariantRequest? request, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (request?.Settings is not null && !CvThemeCatalog.Exists(request.Settings.ThemeId))
return BadRequest("Unknown theme.");
var variant = await _variants.CreateAsync(user.Id, request?.Name, request?.JobApplicationId, request?.Settings, ct);
return Ok(ToDto(variant));
}
[HttpGet("variants/{id:int}")]
public async Task<ActionResult<VariantDto>> Get(int id, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var variant = await _variants.GetAsync(user.Id, id, ct);
return variant is null ? NotFound() : Ok(ToDto(variant));
}
[HttpPut("variants/{id:int}")]
public async Task<ActionResult<VariantDto>> Save(int id, [FromBody] SaveVariantRequest request, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var settings = CvVariantSettingsJson.Normalize(request.Settings);
if (!CvThemeCatalog.Exists(settings.ThemeId)) return BadRequest("Unknown theme.");
var variant = await _variants.SaveAsync(user.Id, id, request.Name, settings, request.Source ?? "autosave", ct);
return variant is null ? NotFound() : Ok(ToDto(variant));
}
[HttpDelete("variants/{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
return await _variants.DeleteAsync(user.Id, id, ct) ? NoContent() : NotFound();
}
[HttpPost("variants/{id:int}/duplicate")]
public async Task<ActionResult<VariantDto>> Duplicate(int id, [FromBody] DuplicateRequest? request, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var variant = await _variants.DuplicateAsync(user.Id, id, request?.Name, ct);
return variant is null ? NotFound() : Ok(ToDto(variant));
}
[HttpPut("variants/{id:int}/public")]
public async Task<ActionResult<VariantDto>> SetPublic(int id, [FromBody] PublicToggleRequest request, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var variant = await _variants.SetPublicAsync(user.Id, id, request.IsPublic, ct);
return variant is null ? NotFound() : Ok(ToDto(variant));
}
[HttpGet("variants/{id:int}/versions")]
public async Task<ActionResult<IEnumerable<CvVariantVersionInfo>>> Versions(int id, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
return Ok(await _variants.ListVersionsAsync(user.Id, id, ct));
}
[HttpPost("variants/{id:int}/versions/{version:int}/restore")]
public async Task<ActionResult<VariantDto>> Restore(int id, int version, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var variant = await _variants.RestoreVersionAsync(user.Id, id, version, ct);
return variant is null ? NotFound() : Ok(ToDto(variant));
}
[HttpGet("variants/{id:int}/preview")]
public async Task<ActionResult<RenderDto>> Preview(int id, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var render = await _variants.RenderAsync(user.Id, id, Person(user), ct);
return render is null ? NotFound() : Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName));
}
// Live preview for unsaved builder settings.
[HttpPost("preview")]
public async Task<ActionResult<RenderDto>> PreviewSettings([FromBody] PreviewRequest request, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var settings = CvVariantSettingsJson.Normalize(request.Settings);
var render = await _variants.RenderSettingsAsync(user.Id, settings, Person(user), ct);
return Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName));
}
[HttpPost("variants/{id:int}/export-pdf")]
public async Task<IActionResult> ExportPdf(int id, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var render = await _variants.RenderAsync(user.Id, id, Person(user), ct);
if (render is null) return NotFound();
var artifact = await _pdf.ExportAsync(new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct);
return File(artifact.Bytes, "application/pdf", artifact.FileName);
}
// AI assistance on any text area. Never mutates the profile or variant — returns a suggestion the
// user reviews and applies themselves. Reuses the existing provider abstraction (ISummarizerService).
[HttpPost("ai/assist")]
public async Task<ActionResult<AiAssistResult>> AiAssist([FromBody] AiAssistRequest request, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var text = (request.Text ?? string.Empty).Trim();
if (text.Length == 0) return BadRequest("Provide text to work on.");
var instruction = BuildAiInstruction(request.Action, request.Role, request.Language, request.Context);
var result = await _ai.SummarizeSectionAsync(instruction, text, 1200, 300);
if (string.IsNullOrWhiteSpace(result))
{
var metrics = await _ai.GetMetricsAsync(ct);
return StatusCode(StatusCodes.Status502BadGateway, metrics.Healthy
? "The AI service returned no usable text."
: "The AI service is unavailable right now.");
}
return Ok(new AiAssistResult(text, result.Trim()));
}
private static string BuildAiInstruction(string? action, string? role, string? language, string? context)
{
var target = string.IsNullOrWhiteSpace(role) ? null : role.Trim();
var lang = string.IsNullOrWhiteSpace(language) ? "the same language as the input" : language.Trim();
var extra = string.IsNullOrWhiteSpace(context) ? string.Empty : $" Context: {context.Trim()}.";
var task = (action ?? "improve").Trim().ToLowerInvariant() switch
{
"professional" => "Rewrite the text in a more professional, confident tone.",
"shorten" => "Make the text more concise without losing meaning.",
"expand" => "Expand the text with more concrete, relevant detail — but never invent facts.",
"grammar" => "Fix grammar, spelling and punctuation only. Keep wording and meaning.",
"ats" => "Rewrite for ATS keyword optimisation: strong action verbs, quantified impact, clear phrasing. Do not invent facts.",
"bullets" => "Rewrite as 35 tight achievement bullet points, each starting with a strong action verb. Do not invent facts.",
"summary" => "Write a concise professional summary (23 sentences) from this text. Do not invent facts.",
"rewrite" => "Rewrite the text more clearly and compellingly, preserving all facts.",
"tailor" => $"Rewrite the text to align with the target role{(target is null ? string.Empty : $" '{target}'")}, emphasising the most relevant experience. Do not invent facts.",
_ => "Improve the clarity, impact and phrasing of the text while preserving all facts.",
};
return $"{task} Preserve every factual claim — never invent employers, titles, dates, or metrics. Write in {lang}. Return only the rewritten text with no preamble.{extra}";
}
private static CvRenderPerson Person(ApplicationUser user)
{
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(name)) name = user.DisplayName?.Trim();
if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim();
if (string.IsNullOrWhiteSpace(name)) name = user.Email?.Trim();
if (string.IsNullOrWhiteSpace(name)) name = "Your Name";
return new CvRenderPerson(name!, user.AvatarImageDataUrl);
}
private static VariantDto ToDto(CvVariant v) => new(
v.Id, v.Name, CvVariantSettingsJson.Deserialize(v.SettingsJson), v.IsPublic, v.PublicSlug, v.Version, v.JobApplicationId, v.UpdatedAtUtc);
}
@@ -0,0 +1,49 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
// Anonymous read-only surface for public CVs: /cv/{slug} in the SPA fetches this. Only variants the
// owner has explicitly toggled public are served; noindex by default. docs/architecture/cv-builder.md.
[ApiController]
[Route("api/public-cv")]
[AllowAnonymous]
public sealed class PublicCvController : ControllerBase
{
private readonly UserManager<ApplicationUser> _users;
private readonly ICvVariantService _variants;
public PublicCvController(UserManager<ApplicationUser> users, ICvVariantService variants)
{
_users = users;
_variants = variants;
}
[HttpGet("{slug}")]
public async Task<IActionResult> Get(string slug, CancellationToken ct)
{
var ownerId = await _variants.GetPublicOwnerAsync(slug, ct);
if (ownerId is null) return NotFound();
var owner = await _users.FindByIdAsync(ownerId);
var person = Person(owner);
var result = await _variants.RenderPublicAsync(slug, person, ct);
if (result is null) return NotFound();
Response.Headers["X-Robots-Tag"] = "noindex, nofollow";
return Ok(new { html = result.Value.render.Html, name = person.FallbackName });
}
private static CvRenderPerson Person(ApplicationUser? user)
{
if (user is null) return new CvRenderPerson("Candidate", null);
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(name)) name = user.DisplayName?.Trim();
if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim();
if (string.IsNullOrWhiteSpace(name)) name = "Candidate";
return new CvRenderPerson(name!, user.AvatarImageDataUrl);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class AddCvVariants : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CvVariants",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
PublicSlug = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
JobApplicationId = table.Column<int>(type: "INTEGER", nullable: true),
SettingsJson = table.Column<string>(type: "TEXT", nullable: false),
IsPublic = table.Column<bool>(type: "INTEGER", nullable: false),
Version = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CvVariants", x => x.Id);
table.ForeignKey(
name: "FK_CvVariants_JobApplications_JobApplicationId",
column: x => x.JobApplicationId,
principalTable: "JobApplications",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "CvVariantVersions",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
CvVariantId = table.Column<int>(type: "INTEGER", nullable: false),
Version = table.Column<int>(type: "INTEGER", nullable: false),
SettingsJson = table.Column<string>(type: "TEXT", nullable: false),
Source = table.Column<string>(type: "TEXT", nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CvVariantVersions", x => x.Id);
table.ForeignKey(
name: "FK_CvVariantVersions_CvVariants_CvVariantId",
column: x => x.CvVariantId,
principalTable: "CvVariants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CvVariants_JobApplicationId",
table: "CvVariants",
column: "JobApplicationId");
migrationBuilder.CreateIndex(
name: "IX_CvVariants_OwnerUserId_UpdatedAtUtc",
table: "CvVariants",
columns: new[] { "OwnerUserId", "UpdatedAtUtc" });
migrationBuilder.CreateIndex(
name: "IX_CvVariants_PublicSlug",
table: "CvVariants",
column: "PublicSlug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CvVariantVersions_CvVariantId_Version",
table: "CvVariantVersions",
columns: new[] { "CvVariantId", "Version" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CvVariantVersions");
migrationBuilder.DropTable(
name: "CvVariants");
}
}
}
@@ -795,6 +795,89 @@ namespace JobTrackerApi.Migrations
b.ToTable("CvUploadArtifacts");
});
modelBuilder.Entity("JobTrackerApi.Models.CvVariant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<int?>("JobApplicationId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PublicSlug")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SettingsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("TEXT");
b.Property<int>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.HasIndex("PublicSlug")
.IsUnique();
b.HasIndex("OwnerUserId", "UpdatedAtUtc");
b.ToTable("CvVariants");
});
modelBuilder.Entity("JobTrackerApi.Models.CvVariantVersion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<int>("CvVariantId")
.HasColumnType("INTEGER");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SettingsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CvVariantId", "Version");
b.ToTable("CvVariantVersions");
});
modelBuilder.Entity("JobTrackerApi.Models.GmailConnection", b =>
{
b.Property<int>("Id")
@@ -1803,6 +1886,27 @@ namespace JobTrackerApi.Migrations
b.Navigation("Artifact");
});
modelBuilder.Entity("JobTrackerApi.Models.CvVariant", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany()
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.CvVariantVersion", b =>
{
b.HasOne("JobTrackerApi.Models.CvVariant", "CvVariant")
.WithMany()
.HasForeignKey("CvVariantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CvVariant");
});
modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
+2
View File
@@ -36,8 +36,10 @@ builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<ICvProcessingQueue, CvProcessingQueue>();
builder.Services.AddTransient<ProfileCvController>();
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
builder.Services.AddSingleton<IThemedCvRenderer, ThemedCvRenderer>();
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
builder.Services.AddSingleton<AppPaths>();
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
@@ -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);
+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();
}
+199
View File
@@ -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");
}
+257
View File
@@ -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("'", "&#39;", 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('-');
}
}
+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;
}
}