feat(career): CV builder backend — data-driven theme engine + variant model
Phase 4 foundation. A CvVariant is a lens over the master CareerProfile
(section order/visibility, per-item overrides keyed by ItemKey, theme +
builder settings) — it references career data, never duplicates it. One
renderer (ThemedCvRenderer) draws every theme; a theme is pure data
(CvThemeCatalog, 8 professional themes), so adding a theme needs no renderer
change. Autosave version history + non-destructive restore, public CV via
/api/public-cv/{slug} (anonymous, noindex, filter-bypassing owner load), and
an AI-assist endpoint reusing the existing provider abstraction (suggestions
only, never auto-applied).
- Models: CvVariant/CvVariantVersion, CvVariantSettings, CvTheme + catalog
- Services: CvVariantResolver (profile+lens -> render model), ThemedCvRenderer,
CvVariantService, CareerProfileService.LoadStructuredForOwnerAsync (public)
- API: CvVariantController (/api/cv), PublicCvController (/api/public-cv)
- Migration AddCvVariants (2 self-contained tables; verified applied on the
running container), 16 tests (resolver/renderer/service), 296 backend green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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 3–5 tight achievement bullet points, each starting with a strong action verb. Do not invent facts.",
|
||||
"summary" => "Write a concise professional summary (2–3 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user