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,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