fix(cv): protect revisions and measure rendering readiness

This commit is contained in:
cesnimda
2026-10-07 07:56:35 +02:00
parent ac6d78289d
commit 046dc304aa
175 changed files with 16252 additions and 41 deletions
@@ -3,6 +3,7 @@ using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
@@ -31,7 +32,8 @@ public sealed class CvVariantController : ControllerBase
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 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);
@@ -126,6 +128,9 @@ public sealed class CvVariantController : ControllerBase
{
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." });
var settings = CvVariantSettingsJson.Normalize(request.Settings);
if (!CvThemeCatalog.Exists(settings.ThemeId)) return BadRequest("Unknown theme.");
if (!await CanUseThemeAsync(user, settings.ThemeId))
@@ -135,8 +140,12 @@ public sealed class CvVariantController : ControllerBase
if (!string.Equals(CvVariantSettingsJson.Deserialize(current.SettingsJson).ThemeId, settings.ThemeId, StringComparison.OrdinalIgnoreCase))
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro.");
}
var variant = await _variants.SaveAsync(user.Id, id, request.Name, settings, request.Source ?? "autosave", ct);
return variant is null ? NotFound() : Ok(ToDto(variant));
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}")]
@@ -174,14 +183,24 @@ public sealed class CvVariantController : ControllerBase
}
[HttpPost("variants/{id:int}/versions/{version:int}/restore")]
public async Task<ActionResult<VariantDto>> Restore(int id, int version, CancellationToken ct)
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();
var variant = await _variants.RestoreVersionAsync(user.Id, id, version, ct);
return variant is null ? NotFound() : Ok(ToDto(variant));
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)
{