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 _users; private readonly ICvVariantService _variants; private readonly ICvPdfExporter _pdf; private readonly ISummarizerService _ai; private readonly IThemedCvRenderer _renderer; public CvVariantController(UserManager 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); 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 async Task>> GetThemes() { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var proThemes = AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes; 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, atsFriendly = t.AtsFriendly, requiresPro = t.Premium, available = proThemes || !t.Premium, swatches = new[] { t.Accent, t.SidebarBg, t.Paper }, }); 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> 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")] public async Task> Outline(CancellationToken ct) { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); return Ok(await _variants.OutlineAsync(user.Id, Person(user), ct)); } [HttpGet("variants")] public async Task>> 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> 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."); 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)); } [HttpGet("variants/{id:int}")] public async Task> 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> 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."); if (!await CanUseThemeAsync(user, settings.ThemeId)) { var current = await _variants.GetAsync(user.Id, id, ct); if (current is null) return NotFound(); if (!string.Equals(CvVariantSettingsJson.Deserialize(current.SettingsJson).ThemeId, settings.ThemeId, StringComparison.OrdinalIgnoreCase)) return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); } 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 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> 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> 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>> 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> 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> 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> PreviewSettings([FromBody] PreviewRequest 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 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 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(user.Id, 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")] [Authorize(Policy = ProEntitlement.Policy)] public async Task> 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.", "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 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 async Task 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))); 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!, AvatarStorage.Resolve(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); }