Files
jobtrackingapp/JobTrackerApi/Controllers/CareerProfileController.cs
T

134 lines
7.1 KiB
C#

using JobTrackerApi.Data;
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 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/overview.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 snapshot = await _career.LoadSnapshotAsync(user.Id, cancellationToken);
return Ok(new CareerProfileDto(snapshot.Profile, CareerCompleteness.Evaluate(snapshot.Profile), snapshot.CvText, snapshot.Revision));
}
/// <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] CareerProfileSaveRequest? 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.");
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
var profile = StructuredCvProfileJson.NormalizeForPersistence(request.Profile);
var error = CareerProfileValidator.Validate(profile);
if (error is not null) return BadRequest(error);
try
{
var dto = await _career.ExecuteAtomicAsync(async () =>
{
var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", request.ExpectedVersion.Value, cancellationToken);
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(saved);
if (request.CvText is not null) user.ProfileCvText = string.IsNullOrWhiteSpace(request.CvText) ? null : request.CvText;
var res = await _users.UpdateAsync(user);
if (!res.Succeeded) throw new CareerProjectionException(string.Join("; ", res.Errors.Select(e => e.Description)));
return new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved), user.ProfileCvText, request.ExpectedVersion.Value + 1);
}, cancellationToken);
return Ok(dto);
}
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload and review your draft before saving." }); }
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
}
/// <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));
}
/// <summary>The append-only version history, newest first.</summary>
[HttpGet("versions")]
public async Task<ActionResult<IReadOnlyList<CareerProfileVersionInfo>>> Versions(CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
return Ok(await _career.ListVersionsAsync(user.Id, cancellationToken));
}
/// <summary>
/// Restores a past version, non-destructively (re-saved as a new version so the restore is
/// itself reversible). Returns the restored profile + refreshed completeness.
/// </summary>
[HttpPost("versions/{version:int}/restore")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<CareerProfileDto>> Restore([FromRoute] int version, [FromBody] CareerProfileRestoreRequest? 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.");
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
try
{
var dto = await _career.ExecuteAtomicAsync(async () =>
{
var restored = await _career.RestoreVersionAsync(user.Id, version, request.ExpectedVersion.Value, cancellationToken);
if (restored is null) return null;
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(restored);
var res = await _users.UpdateAsync(user);
if (!res.Succeeded) throw new CareerProjectionException(string.Join("; ", res.Errors.Select(e => e.Description)));
return new CareerProfileDto(restored, CareerCompleteness.Evaluate(restored), user.ProfileCvText, request.ExpectedVersion.Value + 1);
}, cancellationToken);
return dto is null ? NotFound($"Version {version} was not found.") : Ok(dto);
}
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload before restoring a version." }); }
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
}
}
public sealed record CareerProfileSaveRequest(StructuredCvProfile? Profile, string? CvText, int? ExpectedVersion = null);
public sealed record CareerProfileRestoreRequest(int? ExpectedVersion);
public sealed record CareerProfileDto(StructuredCvProfile Profile, CareerCompletenessDto Completeness, string? CvText, int Revision);
public sealed record CareerCompletenessDto(int Percent, List<string> Missing, List<CareerSectionStatusDto> Sections);
public sealed record CareerSectionStatusDto(string Key, string Label, bool Complete, int Count);