351 lines
20 KiB
C#
351 lines
20 KiB
C#
using JobTrackerApi.Models;
|
||
using JobTrackerApi.Services;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Identity;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
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/overview.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;
|
||
private readonly IThemedCvRenderer _renderer;
|
||
private readonly CvAiSuggestionService? _suggestions;
|
||
|
||
public CvVariantController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai, IThemedCvRenderer? renderer = null, CvAiSuggestionService? suggestions = null)
|
||
{
|
||
_users = users;
|
||
_variants = variants;
|
||
_pdf = pdf;
|
||
_ai = ai;
|
||
_renderer = renderer ?? new ThemedCvRenderer();
|
||
_suggestions = suggestions;
|
||
}
|
||
|
||
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, int? ExpectedVersion = null);
|
||
public sealed record RestoreVariantRequest(int? ExpectedVersion);
|
||
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,
|
||
int? VariantId = null, int? ExpectedVersion = null, string? TargetId = null);
|
||
public sealed record AiApplyRequest(string ProposalToken, string? Result = null);
|
||
|
||
[HttpGet("themes")]
|
||
public async Task<ActionResult<IEnumerable<object>>> 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 },
|
||
supportedSettings = t.SupportedSettings,
|
||
pageMarginMm = t.PageMarginMm,
|
||
});
|
||
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")]
|
||
public async Task<ActionResult<CvRenderModel>> 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<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.");
|
||
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<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();
|
||
if (await _variants.GetAsync(user.Id, id, ct) is null) return NotFound();
|
||
if (request.ExpectedVersion is not > 0)
|
||
return StatusCode(StatusCodes.Status428PreconditionRequired, new { code = "cv_revision_required", message = "Reload this CV before saving." });
|
||
// AI provenance cannot be asserted through an ordinary authored-content save.
|
||
if (request.Source?.Trim().StartsWith("ai:", StringComparison.OrdinalIgnoreCase) == true)
|
||
return BadRequest(new { code = "cv_ai_proposal_required" });
|
||
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.");
|
||
}
|
||
try
|
||
{
|
||
var variant = await _variants.SaveAsync(user.Id, id, request.Name, settings, request.Source ?? "autosave", request.ExpectedVersion.Value, ct);
|
||
return variant is null ? NotFound() : Ok(ToDto(variant));
|
||
}
|
||
catch (DbUpdateConcurrencyException) { return RevisionConflict(); }
|
||
}
|
||
|
||
[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, [FromBody(EmptyBodyBehavior = Microsoft.AspNetCore.Mvc.ModelBinding.EmptyBodyBehavior.Allow)] RestoreVariantRequest? request, CancellationToken ct)
|
||
{
|
||
var user = await _users.GetUserAsync(User);
|
||
if (user is null) return Unauthorized();
|
||
if (await _variants.GetAsync(user.Id, id, ct) is null) return NotFound();
|
||
if (request?.ExpectedVersion is not > 0)
|
||
return StatusCode(StatusCodes.Status428PreconditionRequired, new { code = "cv_revision_required", message = "Reload this CV before restoring." });
|
||
try
|
||
{
|
||
var variant = await _variants.RestoreVersionAsync(user.Id, id, version, request.ExpectedVersion.Value, ct);
|
||
return variant is null ? NotFound() : Ok(ToDto(variant));
|
||
}
|
||
catch (DbUpdateConcurrencyException) { return RevisionConflict(); }
|
||
}
|
||
|
||
private ObjectResult RevisionConflict() => StatusCode(StatusCodes.Status409Conflict,
|
||
new { code = "cv_revision_conflict", message = "This CV was changed elsewhere. Your request was not applied. Reload and review before saving again." });
|
||
|
||
[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);
|
||
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<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(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<ActionResult<CvAiProposal>> AiAssist([FromBody] AiAssistRequest request, CancellationToken ct)
|
||
{
|
||
var user = await _users.GetUserAsync(User);
|
||
if (user is null) return Unauthorized();
|
||
if (request.VariantId is not > 0 || request.ExpectedVersion is not > 0)
|
||
return StatusCode(StatusCodes.Status428PreconditionRequired, new { code = "cv_revision_required" });
|
||
if (_suggestions is null) return StatusCode(503, new { code = "cv_ai_unavailable" });
|
||
try
|
||
{
|
||
return Ok(await _suggestions.GenerateAsync(user.Id, request.VariantId.Value, request.ExpectedVersion.Value,
|
||
request.TargetId, request.Action ?? "improve", request.Text, request.Context,
|
||
BuildAiInstruction(request.Action, request.Role, request.Language, request.Context), ct));
|
||
}
|
||
catch (CvAiSuggestionException error) { return StatusCode(error.Status, new { code = error.Code }); }
|
||
catch (DbUpdateConcurrencyException) { return RevisionConflict(); }
|
||
catch (AiGenerationException) { return StatusCode(502, new { code = "cv_ai_unavailable" }); }
|
||
}
|
||
|
||
[HttpPost("variants/{id:int}/ai/apply")]
|
||
[Authorize(Policy = ProEntitlement.Policy)]
|
||
public async Task<ActionResult<VariantDto>> ApplyAi(int id, [FromBody] AiApplyRequest request, CancellationToken ct)
|
||
{
|
||
var user = await _users.GetUserAsync(User);
|
||
if (user is null) return Unauthorized();
|
||
if (_suggestions is null) return StatusCode(503, new { code = "cv_ai_unavailable" });
|
||
if (string.IsNullOrWhiteSpace(request.ProposalToken)) return BadRequest(new { code = "cv_ai_proposal_invalid" });
|
||
try { return Ok(ToDto(await _suggestions.ApplyAsync(user.Id, id, request.ProposalToken, request.Result, ct))); }
|
||
catch (CvAiSuggestionException error) { return StatusCode(error.Status, new { code = error.Code }); }
|
||
catch (DbUpdateConcurrencyException) { return RevisionConflict(); }
|
||
catch (AiGenerationException) { return StatusCode(502, new { code = "cv_ai_unavailable" }); }
|
||
}
|
||
|
||
private static string BuildAiInstruction(string? action, string? role, string? language, string? context)
|
||
{
|
||
var normalizedAction = (action ?? "improve").Trim().ToLowerInvariant();
|
||
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 = normalizedAction switch
|
||
{
|
||
"document-translate" => "Translate every user-facing CV value into the requested language. Return only valid JSON with the exact same array structure, ids and fields. Preserve names, employers, dates, qualifications, URLs and technical terms unless they have a standard translation.",
|
||
"document-improve" => "Improve clarity, concision and professional impact across the CV. Return only valid JSON with the exact same array structure, ids and fields. Preserve every fact and never invent achievements, metrics, skills or experience.",
|
||
"document-grammar" => "Correct spelling, grammar and punctuation across the CV without changing meaning. Return only valid JSON with the exact same array structure, ids and fields.",
|
||
"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.",
|
||
};
|
||
var outputInstruction = normalizedAction.StartsWith("document-", StringComparison.Ordinal)
|
||
? "Return only the JSON array with no Markdown fence or preamble."
|
||
: "Return only the rewritten text with no preamble.";
|
||
return $"{task} Preserve every factual claim — never invent employers, titles, dates, or metrics. Write in {lang}. {outputInstruction}{extra}";
|
||
}
|
||
|
||
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)));
|
||
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);
|
||
}
|