feat(cv): rebuild professional resume studio

This commit is contained in:
cesnimda
2026-08-24 20:21:23 +02:00
parent dca5daa1a2
commit 367b70681a
27 changed files with 827 additions and 187 deletions
@@ -18,13 +18,15 @@ public sealed class CvVariantController : ControllerBase
private readonly ICvVariantService _variants;
private readonly ICvPdfExporter _pdf;
private readonly ISummarizerService _ai;
private readonly IThemedCvRenderer _renderer;
public CvVariantController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai)
public CvVariantController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai, IThemedCvRenderer? renderer = null)
{
_users = users;
_variants = variants;
_pdf = pdf;
_ai = ai;
_renderer = renderer ?? new ThemedCvRenderer();
}
public sealed record VariantDto(int Id, string Name, CvVariantSettings Settings, bool IsPublic, string PublicSlug, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc);
@@ -61,6 +63,20 @@ public sealed class CvVariantController : ControllerBase
return Ok(themes);
}
// Render-only sample for visual template thumbnails. This in-memory content is never written to
// the authenticated user's master profile or to a CV variant.
[HttpGet("themes/{themeId}/preview")]
public async Task<ActionResult<RenderDto>> GetThemePreview(string themeId)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (!CvThemeCatalog.Exists(themeId)) return NotFound();
var theme = CvThemeCatalog.Resolve(themeId);
var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings { ThemeId = theme.Id, ShowIcons = true });
var render = _renderer.Render(TemplatePreviewModel(), theme, settings);
return Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName));
}
// The master profile as sections+entries (with ItemKeys) — the Content tab reads this to render
// editable rows without duplicating the profile shape on the client.
[HttpGet("outline")]
@@ -88,6 +104,8 @@ public sealed class CvVariantController : ControllerBase
return BadRequest("Unknown theme.");
if (request?.Settings is not null && !await CanUseThemeAsync(user, request.Settings.ThemeId))
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro.");
if (request?.JobApplicationId is int jobApplicationId && !await _variants.CanAssociateJobAsync(user.Id, jobApplicationId, ct))
return BadRequest("The job application is unavailable.");
var variant = await _variants.CreateAsync(user.Id, request?.Name, request?.JobApplicationId, request?.Settings, ct);
return Ok(ToDto(variant));
}
@@ -228,6 +246,7 @@ public sealed class CvVariantController : ControllerBase
"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.",
"impact" => "Strengthen the text by identifying where the user could add measurable impact. Use clearly marked placeholders for missing numbers and never invent a metric or result.",
"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.",
@@ -241,6 +260,34 @@ public sealed class CvVariantController : ControllerBase
private async Task<bool> CanUseThemeAsync(ApplicationUser user, string? themeId) =>
CvThemeCatalog.CanUse(themeId, AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes);
private static CvRenderModel TemplatePreviewModel() => new()
{
FullName = "Alex Morgan",
Headline = "Product & Platform Engineer",
Contact =
[
new() { Icon = "email", Value = "alex@example.com", Href = "mailto:alex@example.com" },
new() { Icon = "location", Value = "Oslo, Norway" },
new() { Icon = "web", Value = "alexmorgan.dev", Href = "https://alexmorgan.dev" },
],
Sections =
[
new() { Key = "summary", Title = "Profile", Kind = "bullets", Bullets = ["Engineer focused on clear products, resilient systems and measurable delivery."] },
new()
{
Key = "experience", Title = "Experience", Kind = "entries",
Entries =
[
new() { Title = "Senior Platform Engineer", Subtitle = "Northstar Labs", Meta = "2022 Present", Bullets = ["Led platform improvements across product teams.", "Reduced release lead time through safer automation."] },
new() { Title = "Software Engineer", Subtitle = "Studio Works", Meta = "2019 2022", Bullets = ["Built accessible customer workflows and APIs."] },
],
},
new() { Key = "skills", Title = "Skills", Kind = "tags", Tags = ["C#", "React", "Cloud", "Design systems", "Delivery"] },
new() { Key = "education", Title = "Education", Kind = "entries", Entries = [new() { Title = "BSc Computer Science", Subtitle = "University of Oslo", Meta = "2016 2019" }] },
new() { Key = "languages", Title = "Languages", Kind = "tags", Tags = ["English", "Norwegian"] },
],
};
private static CvRenderPerson Person(ApplicationUser user)
{
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));