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:
204
backend/src/BigRegister.Api/Data/OrgTemplateStore.cs
Normal file
204
backend/src/BigRegister.Api/Data/OrgTemplateStore.cs
Normal 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 }) },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user