feat(dashboard): add owner-scoped layouts with atomic revisions

This commit is contained in:
cesnimda
2026-10-21 20:59:58 +02:00
parent 421f902d21
commit 5edbe59692
11 changed files with 539 additions and 0 deletions
@@ -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)