feat(fp): WP-23 — org-template backend + admin role

Second template axis (org identity: letterhead, footer, signature,
margins) server-side: OrgTemplateStore with JSON version history,
publish/rollback, sent-brief version pinning, admin role + capability,
5 admin endpoints, org-logo upload category. FE seam widened only
(Role/Capability unions, interceptor); WP-24/26 consume it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 11:17:05 +02:00
parent 44eb2d2186
commit 5a610c10f0
31 changed files with 5017 additions and 1834 deletions

View File

@@ -0,0 +1,204 @@
using BigRegister.Api.Contracts;
namespace BigRegister.Api.Data;
/// <summary>
/// Organization template per sub-organization (WP-23, Brief v2 PRD §3): one row per
/// sub-org. `Draft` is the work-in-progress payload (Version 0), `History` the
/// append-only list of published snapshots, `PublishedVersion` points into it.
/// Rollback copies an old snapshot back into the draft — it never rewrites history.
/// Mirrors <see cref="BriefStore"/>: static class, short-lived context per call,
/// nested DTO shapes stored as JSON text columns (WP-22 posture).
/// </summary>
public sealed class OrgTemplateEntity
{
public required string SubOrgId { get; init; }
public OrgTemplateDto Draft { get; set; } = null!;
public int PublishedVersion { get; set; }
public List<OrgTemplateVersionDto> History { get; set; } = new();
}
/// <summary>ponytail: one global lock, same shape as BriefStore — SQLite tolerates
/// only one writer at a time anyway.</summary>
public static class OrgTemplateStore
{
private static readonly object _gate = new();
public static IReadOnlyList<SubOrgSummaryDto> List()
{
lock (_gate)
{
using var db = Seeded();
return db.OrgTemplates.AsEnumerable()
.Select(e => new SubOrgSummaryDto(e.SubOrgId, Published(e).OrgName, e.PublishedVersion))
.OrderBy(s => s.SubOrgId)
.ToList();
}
}
public static OrgTemplateAdminViewDto? AdminView(string subOrgId)
{
lock (_gate)
{
using var db = Seeded();
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
return e is null ? null : ToAdminView(db, e);
}
}
/// Save the draft. Caller validates first (OrgTemplateRules); null = unknown sub-org.
public static OrgTemplateAdminViewDto? SaveDraft(string subOrgId, OrgTemplateDto draft)
{
lock (_gate)
{
using var db = Seeded();
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
if (e is null) return null;
// Normalize identity fields the client can't be trusted with: the row key and
// the draft marker (Version 0) always win over whatever was posted.
e.Draft = draft with { SubOrgId = subOrgId, Version = 0 };
db.SaveChanges();
return ToAdminView(db, e);
}
}
/// Draft → published: append a snapshot to history, advance the pointer. Returns
/// the new version + how many unsent briefs of this sub-org it re-themes.
public static PublishOrgTemplateResponse? Publish(string subOrgId, string at)
{
lock (_gate)
{
using var db = Seeded();
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
if (e is null) return null;
var version = e.PublishedVersion + 1;
// Reassign (not mutate) the list: the JSON ValueConverter has no ValueComparer,
// so EF only sees the change when the property reference changes.
e.History = new List<OrgTemplateVersionDto>(e.History)
{
new(version, at, e.Draft with { Version = version }),
};
e.PublishedVersion = version;
db.SaveChanges();
return new PublishOrgTemplateResponse(version, CountUnsent(db, subOrgId));
}
}
/// Rollback = copy an old published snapshot into the draft (admin republishes to
/// make it live). History stays append-only. Null = unknown sub-org or version.
public static OrgTemplateAdminViewDto? Rollback(string subOrgId, int version)
{
lock (_gate)
{
using var db = Seeded();
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId);
var old = e?.History.FirstOrDefault(h => h.Version == version);
if (e is null || old is null) return null;
e.Draft = old.Template with { Version = 0 };
db.SaveChanges();
return ToAdminView(db, e);
}
}
/// The template a brief renders with: the pinned version once sent (immutable
/// letters), else the sub-org's current published version.
public static OrgTemplateDto TemplateForBrief(string subOrgId, int? pinnedVersion)
{
lock (_gate)
{
using var db = Seeded();
// ponytail: briefs from before this WP carry an empty SubOrgId — fall back to
// the first seeded sub-org instead of failing the whole screen.
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId)
?? db.OrgTemplates.First(t => t.SubOrgId == OrgTemplateSeed.Registers);
var pinned = pinnedVersion is { } v ? e.History.FirstOrDefault(h => h.Version == v)?.Template : null;
return pinned ?? Published(e);
}
}
public static int PublishedVersionOf(string subOrgId)
{
lock (_gate)
{
using var db = Seeded();
var e = db.OrgTemplates.FirstOrDefault(t => t.SubOrgId == subOrgId)
?? db.OrgTemplates.First(t => t.SubOrgId == OrgTemplateSeed.Registers);
return e.PublishedVersion;
}
}
/// Test seam: drop all rows so the next call re-seeds.
public static void Reset()
{
lock (_gate)
{
using var db = Db.Create();
db.OrgTemplates.RemoveRange(db.OrgTemplates);
db.SaveChanges();
}
}
private static OrgTemplateDto Published(OrgTemplateEntity e) =>
e.History.First(h => h.Version == e.PublishedVersion).Template;
private static OrgTemplateAdminViewDto ToAdminView(AppDbContext db, OrgTemplateEntity e) =>
new(e.Draft, e.PublishedVersion, e.History, CountUnsent(db, e.SubOrgId));
// Status is a JSON column (not translatable to SQL) — count in memory; the demo
// holds a handful of briefs at most.
private static int CountUnsent(AppDbContext db, string subOrgId) =>
db.Briefs.AsEnumerable().Count(b => b.SubOrgId == subOrgId && b.Status.Tag != "sent");
private static AppDbContext Seeded()
{
var db = Db.Create();
if (!db.OrgTemplates.Any())
{
db.OrgTemplates.AddRange(OrgTemplateSeed.All());
db.SaveChanges();
}
return db;
}
}
/// <summary>
/// Two seeded sub-organizations so the admin editor can demonstrate isolation
/// (editing one never touches the other). Values come from the fictitious sample
/// artifact `voorbeeldbrief-inschrijving.pdf`; each starts with draft == published v1.
/// </summary>
public static class OrgTemplateSeed
{
public const string Registers = "cibg-registers";
public const string Vakbekwaamheid = "cibg-vakbekwaamheid";
// Deterministic seed timestamp (stable golden files, stable demos).
private const string SeededAt = "2026-07-01T00:00:00.0000000+00:00";
public static IEnumerable<OrgTemplateEntity> All() => new[]
{
Entity(new OrgTemplateDto(
Registers, "BIG-register",
"Retouradres: Postbus 00000, 2500 AA Den Haag",
LogoDocumentId: null,
"BIG-register · Postbus 00000, 2500 AA Den Haag · 070 000 00 00 · info@voorbeeld.example",
"Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.",
"A. de Vries", "Hoofd Registratie, BIG-register", "Met vriendelijke groet,",
new MarginsDto(25, 20, 20, 25))),
Entity(new OrgTemplateDto(
Vakbekwaamheid, "CIBG Vakbekwaamheid",
"Retouradres: Postbus 11111, 2500 BB Den Haag",
LogoDocumentId: null,
"CIBG Vakbekwaamheid · Postbus 11111, 2500 BB Den Haag · 070 111 11 11 · vakbekwaamheid@voorbeeld.example",
"Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.",
"M. Bakker", "Hoofd Vakbekwaamheid, CIBG", "Met vriendelijke groet,",
new MarginsDto(25, 20, 20, 25))),
};
private static OrgTemplateEntity Entity(OrgTemplateDto v1) => new()
{
SubOrgId = v1.SubOrgId,
Draft = v1 with { Version = 0 },
PublishedVersion = 1,
History = new() { new OrgTemplateVersionDto(1, SeededAt, v1 with { Version = 1 }) },
};
}