feat(dashboard): add owner-scoped layouts with atomic revisions
This commit is contained in:
@@ -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, []);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user