feat(dashboard): add owner-scoped layouts with atomic revisions
This commit is contained in:
@@ -544,6 +544,54 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
public sealed record UpdateLanguagePreferenceRequest(string? Language);
|
||||
|
||||
[HttpGet("preferences/dashboard")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> GetDashboardPreference(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = LocalAuthIdentity.GetRequiredUserId(User);
|
||||
if (userId is null) return Unauthorized();
|
||||
var user = await _db.Users.AsNoTracking().Where(x => x.Id == userId)
|
||||
.Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleOrDefaultAsync(cancellationToken);
|
||||
return user is null ? Unauthorized() : Ok(DashboardLayout.Read(user.DashboardLayoutJson, user.DashboardLayoutRevision));
|
||||
}
|
||||
|
||||
[HttpPut("preferences/dashboard")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> UpdateDashboardPreference(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = LocalAuthIdentity.GetRequiredUserId(User);
|
||||
if (userId is null) return Unauthorized();
|
||||
var existing = await _db.Users.AsNoTracking().Where(x => x.Id == userId)
|
||||
.Select(x => new { x.DashboardLayoutJson }).SingleOrDefaultAsync(cancellationToken);
|
||||
if (existing is null) return Unauthorized();
|
||||
if (DashboardLayout.Read(existing.DashboardLayoutJson, 0).Version > 1)
|
||||
return Conflict(new { error = "dashboard_version_unsupported" });
|
||||
if (Request.ContentLength > DashboardLayout.MaxBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
// Bound actual bytes as well as Content-Length, including chunked bodies.
|
||||
var bytes = new byte[DashboardLayout.MaxBytes + 1];
|
||||
var length = 0;
|
||||
while (length < bytes.Length)
|
||||
{
|
||||
var count = await Request.Body.ReadAsync(bytes.AsMemory(length), cancellationToken);
|
||||
if (count == 0) break;
|
||||
length += count;
|
||||
}
|
||||
if (length > DashboardLayout.MaxBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
DashboardLayout? layout;
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(bytes.AsMemory(0, length));
|
||||
layout = DashboardLayout.Parse(document.RootElement);
|
||||
}
|
||||
catch (JsonException) { return BadRequest("Invalid dashboard layout JSON."); }
|
||||
if (layout is null) return BadRequest("Invalid dashboard layout version, revision or widgets.");
|
||||
var json = JsonSerializer.Serialize(layout, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
var updated = await _db.Users.Where(x => x.Id == userId && x.DashboardLayoutRevision == layout.Revision)
|
||||
.ExecuteUpdateAsync(set => set.SetProperty(x => x.DashboardLayoutJson, json)
|
||||
.SetProperty(x => x.DashboardLayoutRevision, x => x.DashboardLayoutRevision + 1), cancellationToken);
|
||||
return updated == 0 ? Conflict(new { error = "dashboard_revision_conflict" }) : Ok(layout with { Revision = layout.Revision + 1 });
|
||||
}
|
||||
|
||||
[HttpPut("preferences/language")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> UpdateLanguagePreference([FromBody] UpdateLanguagePreferenceRequest request)
|
||||
|
||||
@@ -101,6 +101,15 @@ namespace JobTrackerApi.Data
|
||||
.Property(x => x.UiLanguage)
|
||||
.HasMaxLength(16);
|
||||
|
||||
modelBuilder.Entity<ApplicationUser>()
|
||||
.Property(x => x.DashboardLayoutRevision)
|
||||
.HasDefaultValue(0L);
|
||||
// Layout writes use the revision-guarded endpoint; Identity's whole-user update must not replay stale values.
|
||||
modelBuilder.Entity<ApplicationUser>().Property(x => x.DashboardLayoutJson)
|
||||
.Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore);
|
||||
modelBuilder.Entity<ApplicationUser>().Property(x => x.DashboardLayoutRevision)
|
||||
.Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore);
|
||||
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerKey).HasMaxLength(64);
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.RequestedByUserId).HasMaxLength(255);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using JobTrackerApi.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobTrackerApi.Migrations;
|
||||
|
||||
[DbContext(typeof(JobTrackerContext))]
|
||||
[Migration("20261021090000_AddDashboardLayoutPreference")]
|
||||
public partial class AddDashboardLayoutPreference : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
var mysql = ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase);
|
||||
migrationBuilder.AddColumn<string>(name: "DashboardLayoutJson", table: "AspNetUsers", type: mysql ? "longtext" : "TEXT", nullable: true);
|
||||
migrationBuilder.AddColumn<long>(name: "DashboardLayoutRevision", table: "AspNetUsers", type: mysql ? "bigint" : "INTEGER", nullable: false, defaultValue: 0L);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
if (ActiveProvider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// These independent columns need no table rebuild (SQLite 3.35+ supports DROP COLUMN).
|
||||
migrationBuilder.Sql("ALTER TABLE \"AspNetUsers\" DROP COLUMN \"DashboardLayoutJson\";");
|
||||
migrationBuilder.Sql("ALTER TABLE \"AspNetUsers\" DROP COLUMN \"DashboardLayoutRevision\";");
|
||||
return;
|
||||
}
|
||||
migrationBuilder.DropColumn(name: "DashboardLayoutJson", table: "AspNetUsers");
|
||||
migrationBuilder.DropColumn(name: "DashboardLayoutRevision", table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
@@ -485,6 +485,16 @@ namespace JobTrackerApi.Migrations
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DashboardLayoutJson")
|
||||
.HasColumnType("TEXT")
|
||||
.Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore);
|
||||
|
||||
b.Property<long>("DashboardLayoutRevision")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0L)
|
||||
.Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore);
|
||||
|
||||
b.Property<DateTimeOffset?>("TotpEnabledAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ public sealed class ApplicationUser : IdentityUser
|
||||
public bool EmailFollowUpRemindersEnabled { get; set; } = true;
|
||||
// BCP 47 application UI preference. Null/legacy values safely fall back to English.
|
||||
public string? UiLanguage { get; set; }
|
||||
public string? DashboardLayoutJson { get; set; }
|
||||
public long DashboardLayoutRevision { get; set; }
|
||||
public string DeletionStatus { get; set; } = AccountDeletionStatuses.Active;
|
||||
public DateTimeOffset? DeletionRequestedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed record DashboardWidget(string Id, bool Visible, string Size);
|
||||
public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList<DashboardWidget> Widgets)
|
||||
{
|
||||
public const int MaxBytes = 16 * 1024;
|
||||
// Responses cross a JavaScript client; the incremented revision must remain an exact integer there.
|
||||
private const long MaxRevision = 9_007_199_254_740_991L;
|
||||
private static readonly HashSet<string> KnownIds = new(StringComparer.Ordinal)
|
||||
{ "cards", "attention", "funnel", "activity", "stageAge", "sources", "salary", "companies", "skills" };
|
||||
|
||||
public static DashboardLayout? Parse(JsonElement root, bool reading = false, long revision = 0)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("version", out var version) || version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out var v) || v != 1
|
||||
|| !root.TryGetProperty("widgets", out var widgets) || widgets.ValueKind != JsonValueKind.Array || widgets.GetArrayLength() > 32) return null;
|
||||
if (!reading && (!root.TryGetProperty("revision", out var rev) || rev.ValueKind != JsonValueKind.Number || !rev.TryGetInt64(out revision) || revision < 0 || revision >= MaxRevision)) return null;
|
||||
var result = new List<DashboardWidget>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var widget in widgets.EnumerateArray())
|
||||
{
|
||||
if (widget.ValueKind != JsonValueKind.Object || !widget.TryGetProperty("id", out var idValue) || idValue.ValueKind != JsonValueKind.String
|
||||
|| !KnownIds.Contains(idValue.GetString()!) || !seen.Add(idValue.GetString()!)
|
||||
|| !widget.TryGetProperty("visible", out var visible) || (visible.ValueKind != JsonValueKind.True && visible.ValueKind != JsonValueKind.False))
|
||||
{
|
||||
if (reading) continue;
|
||||
return null;
|
||||
}
|
||||
var id = idValue.GetString()!;
|
||||
var size = widget.TryGetProperty("size", out var sizeValue) && sizeValue.ValueKind == JsonValueKind.String ? sizeValue.GetString() : null;
|
||||
if (size is not ("standard" or "full") || (id == "cards" && size != "full"))
|
||||
{
|
||||
if (!reading) return null;
|
||||
size = id == "cards" ? "full" : "standard";
|
||||
}
|
||||
result.Add(new DashboardWidget(id, visible.GetBoolean(), size));
|
||||
}
|
||||
return new DashboardLayout(1, revision, result);
|
||||
}
|
||||
|
||||
public static DashboardLayout Read(string? json, long revision)
|
||||
{
|
||||
if (json is not null && Encoding.UTF8.GetByteCount(json) <= MaxBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& document.RootElement.TryGetProperty("version", out var version) && version.ValueKind == JsonValueKind.Number
|
||||
&& version.TryGetInt32(out var futureVersion) && futureVersion > 1)
|
||||
return new(futureVersion, revision, []);
|
||||
return Parse(document.RootElement, reading: true, revision) ?? new(1, revision, []);
|
||||
}
|
||||
catch (JsonException) { }
|
||||
}
|
||||
return new(1, revision, []);
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,7 @@ public static class StartupInitializationExtensions
|
||||
EnsureColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE AspNetUsers ADD COLUMN StripeLastEventCreatedUtc TEXT NULL;");
|
||||
// UiLanguage is migration-owned (AddUiLanguagePreference). Adding it here before
|
||||
// the per-migration loop makes a fresh database fail when that migration runs.
|
||||
// DashboardLayoutJson/Revision likewise belong to AddDashboardLayoutPreference.
|
||||
|
||||
static void ReconcileGmailConnectionColumns(DbConnection c)
|
||||
{
|
||||
@@ -539,6 +540,7 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeLastEventCreatedUtc` datetime(6) NULL;");
|
||||
// UiLanguage is migration-owned; the final reconciliation pass observes it
|
||||
// after AddUiLanguagePreference instead of racing the migration.
|
||||
// DashboardLayoutJson/Revision are also migration-owned for both providers.
|
||||
|
||||
// RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler
|
||||
// used to create it too, which made a clean install fail with "Table 'RuleSettings'
|
||||
|
||||
Reference in New Issue
Block a user