Files
jobtrackingapp/JobTrackerApi/Controllers/CvVariantController.cs
T
cesnimda 4cf26405f6
CI and Deploy / test (push) Failing after 1m6s
CI and Deploy / deploy (push) Has been skipped
feat: complete phase 3 career workspace
2026-07-30 22:19:13 +02:00

238 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
atsFriendly = t.AtsFriendly,
swatches = new[] { t.Accent, t.SidebarBg, t.Paper },
});
return Ok(themes);
}
// 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.");
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 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.",
"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!, 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);
}