using BigRegister.Api.Contracts; namespace BigRegister.Api.Data; /// /// 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 : static class, short-lived context per call, /// nested DTO shapes stored as JSON text columns (WP-22 posture). /// public sealed class OrgTemplateEntity { public required string SubOrgId { get; init; } public OrgTemplateDto Draft { get; set; } = null!; public int PublishedVersion { get; set; } public List History { get; set; } = new(); } /// ponytail: one global lock, same shape as BriefStore — SQLite tolerates /// only one writer at a time anyway. public static class OrgTemplateStore { private static readonly object _gate = new(); public static IReadOnlyList 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(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; } } /// /// 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. /// 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 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 }) }, }; }