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 _users; private readonly ICareerProfileService _career; private readonly JobTrackerContext _db; public CareerProfileController(UserManager users, ICareerProfileService career, JobTrackerContext db) { _users = users; _career = career; _db = db; } /// The master career profile, assembled from the relational children. [HttpGet] public async Task> 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)); } /// /// 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). /// [HttpPut] [Authorize(AuthenticationSchemes = "local")] public async Task> 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); } } /// Just the completeness scorecard, for the /career overview. [HttpGet("completeness")] public async Task> 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)); } /// The append-only version history, newest first. [HttpGet("versions")] public async Task>> Versions(CancellationToken cancellationToken) { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); return Ok(await _career.ListVersionsAsync(user.Id, cancellationToken)); } /// /// Restores a past version, non-destructively (re-saved as a new version so the restore is /// itself reversible). Returns the restored profile + refreshed completeness. /// [HttpPost("versions/{version:int}/restore")] [Authorize(AuthenticationSchemes = "local")] public async Task> 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 Missing, List Sections); public sealed record CareerSectionStatusDto(string Key, string Label, bool Complete, int Count);