fix(cv): protect revisions and measure rendering readiness
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -480,6 +480,9 @@ namespace JobTrackerApi.Data
|
||||
// the reconciler (StartupInitializationExtensions), MySQL-safe; see that file + DbContext note.
|
||||
modelBuilder.Entity<CvVariant>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||
modelBuilder.Entity<CvVariant>().Property(x => x.PublicSlug).HasMaxLength(64);
|
||||
// Existing integer column: make the UPDATE compare the loaded revision atomically.
|
||||
// No new database column or schema migration is required.
|
||||
modelBuilder.Entity<CvVariant>().Property(x => x.Version).IsConcurrencyToken();
|
||||
modelBuilder.Entity<CvVariantVersion>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||
modelBuilder.Entity<CvVariant>()
|
||||
.HasIndex(x => x.PublicSlug)
|
||||
|
||||
@@ -1313,6 +1313,7 @@ namespace JobTrackerApi.Migrations
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
@@ -28,12 +28,12 @@ public interface ICvVariantService
|
||||
Task<bool> CanAssociateJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
|
||||
Task<CvVariant?> GetAsync(string ownerUserId, int id, CancellationToken ct);
|
||||
Task<CvVariant> CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct);
|
||||
Task<CvVariant?> SaveAsync(string ownerUserId, int id, string? name, CvVariantSettings settings, string source, CancellationToken ct);
|
||||
Task<CvVariant?> SaveAsync(string ownerUserId, int id, string? name, CvVariantSettings settings, string source, int expectedVersion, CancellationToken ct);
|
||||
Task<CvVariant?> SetPublicAsync(string ownerUserId, int id, bool isPublic, CancellationToken ct);
|
||||
Task<CvVariant?> DuplicateAsync(string ownerUserId, int id, string? newName, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct);
|
||||
Task<IReadOnlyList<CvVariantVersionInfo>> ListVersionsAsync(string ownerUserId, int id, CancellationToken ct);
|
||||
Task<CvVariant?> RestoreVersionAsync(string ownerUserId, int id, int version, CancellationToken ct);
|
||||
Task<CvVariant?> RestoreVersionAsync(string ownerUserId, int id, int version, int expectedVersion, CancellationToken ct);
|
||||
|
||||
// Render a saved variant against the current master profile.
|
||||
Task<ThemedCvRenderResult?> RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct);
|
||||
@@ -108,10 +108,12 @@ public sealed class CvVariantService : ICvVariantService
|
||||
return variant;
|
||||
}
|
||||
|
||||
public async Task<CvVariant?> SaveAsync(string ownerUserId, int id, string? name, CvVariantSettings settings, string source, CancellationToken ct)
|
||||
public async Task<CvVariant?> SaveAsync(string ownerUserId, int id, string? name, CvVariantSettings settings, string source, int expectedVersion, CancellationToken ct)
|
||||
{
|
||||
var variant = await GetAsync(ownerUserId, id, ct);
|
||||
if (variant is null) return null;
|
||||
if (expectedVersion < 1 || variant.Version != expectedVersion)
|
||||
throw new DbUpdateConcurrencyException("The CV changed after it was loaded.");
|
||||
if (!string.IsNullOrWhiteSpace(name)) variant.Name = CleanName(name);
|
||||
variant.SettingsJson = CvVariantSettingsJson.Serialize(settings);
|
||||
variant.Version += 1;
|
||||
@@ -164,14 +166,14 @@ public sealed class CvVariantService : ICvVariantService
|
||||
return versions.Select(x => new CvVariantVersionInfo(x.Version, x.Source, x.CreatedAtUtc, x.Version == variant.Version)).ToList();
|
||||
}
|
||||
|
||||
public async Task<CvVariant?> RestoreVersionAsync(string ownerUserId, int id, int version, CancellationToken ct)
|
||||
public async Task<CvVariant?> RestoreVersionAsync(string ownerUserId, int id, int version, int expectedVersion, CancellationToken ct)
|
||||
{
|
||||
var variant = await GetAsync(ownerUserId, id, ct);
|
||||
if (variant is null) return null;
|
||||
var target = await _db.CvVariantVersions.FirstOrDefaultAsync(x => x.CvVariantId == id && x.Version == version, ct);
|
||||
if (target is null) return null;
|
||||
var settings = CvVariantSettingsJson.Deserialize(target.SettingsJson);
|
||||
return await SaveAsync(ownerUserId, id, null, settings, $"restore:v{version}", ct);
|
||||
return await SaveAsync(ownerUserId, id, null, settings, $"restore:v{version}", expectedVersion, ct);
|
||||
}
|
||||
|
||||
public async Task<ThemedCvRenderResult?> RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct)
|
||||
|
||||
@@ -208,6 +208,7 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
||||
"--disable-dev-shm-usage",
|
||||
"--allow-file-access-from-files",
|
||||
"--enable-local-file-accesses",
|
||||
"--no-pdf-header-footer",
|
||||
$"--print-to-pdf={storagePath}",
|
||||
new Uri(htmlPath).AbsoluteUri
|
||||
};
|
||||
|
||||
@@ -294,7 +294,7 @@ h1,h2{{font-family:{headingFont};}}
|
||||
.section-presentation-compact>.bullets li{{margin-bottom:{F(.75 * density)}mm;}}
|
||||
.section-presentation-bubble>.tags,.section-presentation-bubble>.bullets{{list-style:none;padding:0;display:flex;flex-wrap:wrap;gap:1.4mm;}}
|
||||
.section-presentation-bubble>.bullets li,.section-presentation-bubble>.tags li{{margin:0;border:1px solid var(--cv-accent-soft);border-radius:999px;padding:.7mm 2.2mm;}}
|
||||
.section-presentation-bubble>.bullets li::marker{{content:"";}}
|
||||
.section-presentation-bubble>.bullets li::marker{{content:"""";}}
|
||||
.bullets{{margin:0;padding-left:4.5mm;}}
|
||||
.bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}}
|
||||
.bullets li::marker{{color:var(--cv-accent-color);}}
|
||||
|
||||
Reference in New Issue
Block a user