feat(career): master career profile API
CI and Deploy / test (push) Failing after 1m56s
CI and Deploy / deploy (push) Has been skipped

Phase 3, API layer. GET/PUT /api/career/profile — the endpoint the /career editor
uses to read and write the master profile.

- GET: returns the structured profile (assembled from the relational children,
  backfilled from the blob if needed) plus a completeness scorecard.
- PUT: validates limits, persists via CareerProfileService (relational children +
  append-only version), then serializes the result into
  ApplicationUser.ProfileCvStructureJson so the legacy read paths stay in sync.
  Identity fields are untouched (they belong to /profile).
- GET /completeness: just the scorecard, for the overview.
- CareerCompleteness: weighted percent + missing sections.
- CareerProfileValidator: item-count/length limits (abuse guard, NOT completeness
  — a work-in-progress profile always saves).

Tests (+4): put/get round-trip + projection sync, completeness, over-limit
rejection, empty WIP profile accepted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 00:43:26 +02:00
parent 46ff9454a8
commit b203120ab4
4 changed files with 282 additions and 0 deletions
@@ -0,0 +1,83 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
// Phase 3: the master career profile API. /career reads and writes the structured profile through
// here. The relational children (via CareerProfileService) are the source of truth; the
// ApplicationUser.ProfileCvStructureJson blob is kept in sync as a derived projection so the legacy
// read paths (CV rendering, tailoring, match-score) keep working. See
// docs/architecture/career-profile-model.md.
[ApiController]
[Route("api/career/profile")]
[Authorize]
public sealed class CareerProfileController : ControllerBase
{
private readonly UserManager<ApplicationUser> _users;
private readonly ICareerProfileService _career;
private readonly JobTrackerContext _db;
public CareerProfileController(UserManager<ApplicationUser> users, ICareerProfileService career, JobTrackerContext db)
{
_users = users;
_career = career;
_db = db;
}
/// <summary>The master career profile, assembled from the relational children.</summary>
[HttpGet]
public async Task<ActionResult<CareerProfileDto>> Get(CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken);
return Ok(new CareerProfileDto(profile, CareerCompleteness.Evaluate(profile)));
}
/// <summary>
/// Replaces the master career profile. Persists the relational children + an append-only
/// version, then serializes the result back into the ProfileCvStructureJson projection so the
/// legacy readers stay consistent. Identity fields are untouched (they belong to /profile).
/// </summary>
[HttpPut]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<CareerProfileDto>> Put([FromBody] StructuredCvProfile? request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts.");
var profile = StructuredCvProfileJson.Normalize(request);
var error = CareerProfileValidator.Validate(profile);
if (error is not null) return BadRequest(error);
var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", cancellationToken);
// Keep the derived projection in sync for legacy readers.
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(saved);
var res = await _users.UpdateAsync(user);
if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
return Ok(new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved)));
}
/// <summary>Just the completeness scorecard, for the /career overview.</summary>
[HttpGet("completeness")]
public async Task<ActionResult<CareerCompletenessDto>> Completeness(CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken);
return Ok(CareerCompleteness.Evaluate(profile));
}
}
public sealed record CareerProfileDto(StructuredCvProfile Profile, CareerCompletenessDto Completeness);
public sealed record CareerCompletenessDto(int Percent, List<string> Missing, List<CareerSectionStatusDto> Sections);
public sealed record CareerSectionStatusDto(string Key, string Label, bool Complete, int Count);