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:
@@ -151,10 +151,43 @@ public sealed record BriefDto(
|
||||
// (PRD-0002 phase P1) — the FE renders these, it never recomputes them.
|
||||
public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanReject, bool CanSend);
|
||||
|
||||
public sealed record BriefViewDto(BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions);
|
||||
// The brief's screen DTO also carries the org template it renders with (WP-23):
|
||||
// the sub-org's current PUBLISHED version — or, once sent, the version pinned at
|
||||
// send time (sent letters are immutable; a republish never re-renders them).
|
||||
public sealed record BriefViewDto(
|
||||
BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions,
|
||||
OrgTemplateDto OrgTemplate);
|
||||
|
||||
public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
|
||||
public sealed record RejectBriefRequest(string Comments);
|
||||
|
||||
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks.
|
||||
public sealed record MeDto(IReadOnlyList<string> Capabilities);
|
||||
|
||||
// --- Organization templates (WP-23, Brief v2 PRD §3) ---
|
||||
// The second template axis: appearance/identity per sub-organization (letterhead,
|
||||
// footer, signature, margins). Orthogonal to the case-type template (sections +
|
||||
// placeholders); the two only meet at render time.
|
||||
|
||||
public sealed record MarginsDto(int TopMm, int RightMm, int BottomMm, int LeftMm);
|
||||
|
||||
// Version: 0 = a draft (work in progress), n>0 = the published snapshot it is.
|
||||
public sealed record OrgTemplateDto(
|
||||
string SubOrgId, string OrgName, string ReturnAddress, string? LogoDocumentId,
|
||||
string FooterContact, string FooterLegal,
|
||||
string SignatureName, string SignatureRole, string SignatureClosing,
|
||||
MarginsDto Margins, int Version = 0);
|
||||
|
||||
public sealed record OrgTemplateVersionDto(int Version, string PublishedAt, OrgTemplateDto Template);
|
||||
|
||||
// Screen DTO for the admin editor: the editable draft, what's live, the append-only
|
||||
// history, and the publish-impact count ("dit raakt N nog niet verzonden brieven").
|
||||
public sealed record OrgTemplateAdminViewDto(
|
||||
OrgTemplateDto Draft, int PublishedVersion,
|
||||
IReadOnlyList<OrgTemplateVersionDto> History, int UnsentBriefs);
|
||||
|
||||
public sealed record SubOrgSummaryDto(string SubOrgId, string OrgName, int PublishedVersion);
|
||||
|
||||
public sealed record SaveOrgTemplateRequest(OrgTemplateDto Draft);
|
||||
|
||||
public sealed record PublishOrgTemplateResponse(int Version, int AffectedUnsentBriefs);
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
|
||||
public DbSet<Aanvraag> Applications => Set<Aanvraag>();
|
||||
public DbSet<BriefEntity> Briefs => Set<BriefEntity>();
|
||||
public DbSet<OrgTemplateEntity> OrgTemplates => Set<OrgTemplateEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -47,6 +48,13 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
e.Property(b => b.Sections).HasConversion(Json<List<LetterSectionDto>>());
|
||||
e.Property(b => b.Status).HasConversion(Json<BriefStatusDto>());
|
||||
});
|
||||
|
||||
modelBuilder.Entity<OrgTemplateEntity>(e =>
|
||||
{
|
||||
e.HasKey(t => t.SubOrgId);
|
||||
e.Property(t => t.Draft).HasConversion(Json<OrgTemplateDto>());
|
||||
e.Property(t => t.History).HasConversion(Json<List<OrgTemplateVersionDto>>());
|
||||
});
|
||||
}
|
||||
|
||||
// A wizard's draft is an opaque JsonElement snapshot — stored as its raw JSON
|
||||
|
||||
@@ -22,6 +22,10 @@ public sealed class BriefEntity
|
||||
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
|
||||
public List<LetterSectionDto> Sections { get; set; } = new();
|
||||
public BriefStatusDto Status { get; set; } = new("draft");
|
||||
/// Which sub-organization's org template themes this letter (WP-23).
|
||||
public string SubOrgId { get; set; } = OrgTemplateSeed.Registers;
|
||||
/// Pinned at send: sent letters are immutable, a republish never re-themes them.
|
||||
public int? SentOrgTemplateVersion { get; set; }
|
||||
|
||||
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
|
||||
}
|
||||
@@ -33,6 +37,7 @@ public static class BriefStore
|
||||
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
|
||||
public const string DrafterId = "demo-drafter";
|
||||
public const string ApproverId = "demo-approver";
|
||||
public const string AdminId = "demo-admin";
|
||||
|
||||
public enum Outcome { Ok, Forbidden, Conflict }
|
||||
|
||||
@@ -102,6 +107,9 @@ public static class BriefStore
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
|
||||
e.Status = new BriefStatusDto("sent", SentAt: at);
|
||||
// Pin the org-template version the letter was sent with (WP-23): from here on
|
||||
// its appearance is frozen — republishing the template touches unsent briefs only.
|
||||
e.SentOrgTemplateVersion = OrgTemplateStore.PublishedVersionOf(e.SubOrgId);
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using BigRegister.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BigRegister.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260705085857_OrgTemplates")]
|
||||
partial class OrgTemplates
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("AutoApprovable")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DocumentIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Draft")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reden")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Referentie")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("StepCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StepIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Submitted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Applications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Actor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DocumentId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AuditEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
|
||||
{
|
||||
b.Property<string>("BriefId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Beroep")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DrafterId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Placeholders")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Sections")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SentOrgTemplateVersion")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SubOrgId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TemplateId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BriefId");
|
||||
|
||||
b.HasIndex("Owner")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Briefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
|
||||
{
|
||||
b.Property<string>("SubOrgId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Draft")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("History")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PublishedVersion")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("SubOrgId");
|
||||
|
||||
b.ToTable("OrgTemplates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
|
||||
{
|
||||
b.Property<string>("DocumentId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Linked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LocalId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("UploadedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WizardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("DocumentId");
|
||||
|
||||
b.ToTable("Documents");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BigRegister.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OrgTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SentOrgTemplateVersion",
|
||||
table: "Briefs",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SubOrgId",
|
||||
table: "Briefs",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
// Briefs from before this migration adopt the seeded default sub-org.
|
||||
defaultValue: "cibg-registers");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OrgTemplates",
|
||||
columns: table => new
|
||||
{
|
||||
SubOrgId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Draft = table.Column<string>(type: "TEXT", nullable: false),
|
||||
PublishedVersion = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
History = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OrgTemplates", x => x.SubOrgId);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "OrgTemplates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SentOrgTemplateVersion",
|
||||
table: "Briefs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubOrgId",
|
||||
table: "Briefs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,10 +124,17 @@ namespace BigRegister.Api.Data.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SentOrgTemplateVersion")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SubOrgId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TemplateId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
@@ -140,6 +147,27 @@ namespace BigRegister.Api.Data.Migrations
|
||||
b.ToTable("Briefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
|
||||
{
|
||||
b.Property<string>("SubOrgId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Draft")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("History")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PublishedVersion")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("SubOrgId");
|
||||
|
||||
b.ToTable("OrgTemplates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
|
||||
{
|
||||
b.Property<string>("DocumentId")
|
||||
|
||||
@@ -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 }) },
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using BigRegister.Api.Data;
|
||||
|
||||
namespace BigRegister.Domain.Authorization;
|
||||
|
||||
public enum PrincipalRole { Drafter, Approver }
|
||||
public enum PrincipalRole { Drafter, Approver, Admin }
|
||||
|
||||
/// <summary>
|
||||
/// The acting identity for this request. dev stub — NOT a security boundary: resolved
|
||||
@@ -24,30 +24,46 @@ public enum BriefAction { Approve, Reject, Send }
|
||||
/// </summary>
|
||||
public static class Authz
|
||||
{
|
||||
public static Principal ResolvePrincipal(HttpContext ctx) =>
|
||||
new(ctx.Request.Headers["X-Role"].ToString() == "approver" ? PrincipalRole.Approver : PrincipalRole.Drafter);
|
||||
public static Principal ResolvePrincipal(HttpContext ctx) => new(ctx.Request.Headers["X-Role"].ToString() switch
|
||||
{
|
||||
"approver" => PrincipalRole.Approver,
|
||||
"admin" => PrincipalRole.Admin,
|
||||
_ => PrincipalRole.Drafter,
|
||||
});
|
||||
|
||||
public static string ActingId(Principal principal) =>
|
||||
principal.Role == PrincipalRole.Approver ? BriefStore.ApproverId : BriefStore.DrafterId;
|
||||
public static string ActingId(Principal principal) => principal.Role switch
|
||||
{
|
||||
PrincipalRole.Approver => BriefStore.ApproverId,
|
||||
PrincipalRole.Admin => BriefStore.AdminId,
|
||||
_ => BriefStore.DrafterId,
|
||||
};
|
||||
|
||||
/// Coarse, resource-independent capabilities for `GET /me` (nav/menu-level — NOT
|
||||
/// tied to any specific brief's live status; contrast Decisions below).
|
||||
public static IReadOnlyList<string> RoleCapabilities(Principal principal) =>
|
||||
principal.Role == PrincipalRole.Approver
|
||||
? new[] { "brief:approve", "brief:reject", "brief:send" }
|
||||
: Array.Empty<string>();
|
||||
public static IReadOnlyList<string> RoleCapabilities(Principal principal) => principal.Role switch
|
||||
{
|
||||
PrincipalRole.Approver => new[] { "brief:approve", "brief:reject", "brief:send" },
|
||||
PrincipalRole.Admin => new[] { "orgtemplate:edit" },
|
||||
_ => Array.Empty<string>(),
|
||||
};
|
||||
|
||||
/// Role + four-eyes (SoD) check only — no status. This is the exact check
|
||||
/// BriefStore.Review enforces before its status guard; kept separate from
|
||||
/// Decisions() below so enforcement ORDER (Forbidden before Conflict) matches
|
||||
/// today's behavior exactly.
|
||||
/// today's behavior exactly. The explicit Approver condition keeps the new Admin
|
||||
/// role out of the review flow (WP-23) — SoD alone would have let it through.
|
||||
public static bool CanActOn(BriefAction action, Principal principal, string drafterId) => action switch
|
||||
{
|
||||
BriefAction.Approve or BriefAction.Reject => ActingId(principal) != drafterId,
|
||||
BriefAction.Approve or BriefAction.Reject =>
|
||||
principal.Role == PrincipalRole.Approver && ActingId(principal) != drafterId,
|
||||
BriefAction.Send => true, // sending is a mechanical dispatch step, not role-gated today
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// Org-template management (WP-23): admin-only, resource-independent — templates
|
||||
/// have no per-resource state to weigh, so role IS the whole decision here.
|
||||
public static bool CanManageOrgTemplates(Principal principal) => principal.Role == PrincipalRole.Admin;
|
||||
|
||||
/// Resource-aware decision for the screen DTO: "would this action succeed right
|
||||
/// now" — role/SoD AND the brief's current status. This is what the UI renders;
|
||||
/// it never re-derives these booleans itself.
|
||||
|
||||
@@ -19,6 +19,7 @@ public static class DocumentRules
|
||||
{
|
||||
private static readonly string[] Pdf = { "application/pdf" };
|
||||
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
|
||||
private static readonly string[] Image = { "image/jpeg", "image/png" };
|
||||
|
||||
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
|
||||
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
|
||||
@@ -42,6 +43,13 @@ public static class DocumentRules
|
||||
new DocumentCategory("nascholing", "Nascholingscertificaten",
|
||||
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
|
||||
},
|
||||
// WP-23: the admin's org-template logo rides the same upload machinery as the
|
||||
// wizard documents — one category under its own "wizard" id.
|
||||
"org-template" => new[]
|
||||
{
|
||||
new DocumentCategory("org-logo", "Organisatielogo",
|
||||
"Upload het logo van de organisatie (PNG of JPG).", false, Image, 1, false, false),
|
||||
},
|
||||
_ => Array.Empty<DocumentCategory>(),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
|
||||
namespace BigRegister.Domain.Letters;
|
||||
|
||||
/// <summary>
|
||||
/// SERVER-OWNED org-template validation (Brief v2 PRD §5): bounded margins so an
|
||||
/// admin cannot break the letter geometry, and the identity fields a letter cannot
|
||||
/// render without. The FE mirrors the bounds for instant feedback (config values on
|
||||
/// the wire if ever needed); this is the authority.
|
||||
/// </summary>
|
||||
public static class OrgTemplateRules
|
||||
{
|
||||
public const int MarginMinMm = 10;
|
||||
public const int MarginMaxMm = 50;
|
||||
|
||||
/// Returns a rejection reason, or null when the draft is acceptable.
|
||||
public static string? RejectDraft(OrgTemplateDto draft)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(draft.OrgName)) return "Organisatienaam is verplicht.";
|
||||
if (string.IsNullOrWhiteSpace(draft.SignatureName)) return "Naam van de ondertekenaar is verplicht.";
|
||||
var m = draft.Margins;
|
||||
var outOfBounds = new[] { m.TopMm, m.RightMm, m.BottomMm, m.LeftMm }
|
||||
.Any(v => v is < MarginMinMm or > MarginMaxMm);
|
||||
return outOfBounds
|
||||
? $"Marges moeten tussen {MarginMinMm} en {MarginMaxMm} mm liggen."
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using BigRegister.Domain.Authorization;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Documents;
|
||||
using BigRegister.Domain.Intake;
|
||||
using BigRegister.Domain.Letters;
|
||||
using BigRegister.Domain.Registrations;
|
||||
using BigRegister.Domain.Submissions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -354,16 +355,77 @@ api.MapPost("/brief/reset", (HttpContext ctx) =>
|
||||
.WithName("briefReset")
|
||||
.Produces<BriefViewDto>();
|
||||
|
||||
// --- Organization templates (WP-23): the second template axis — appearance and
|
||||
// identity per sub-organization. Admin-only (X-Role: admin, the same dev-stub seam
|
||||
// as drafter/approver); the same Authz check gates every endpoint and feeds the
|
||||
// `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. ---
|
||||
|
||||
api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
Results.Ok(OrgTemplateStore.List())))
|
||||
.WithName("orgTemplates")
|
||||
.Produces<List<SubOrgSummaryDto>>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound()))
|
||||
.WithName("orgTemplateGET")
|
||||
.Produces<OrgTemplateAdminViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRequest req, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
{
|
||||
var reject = OrgTemplateRules.RejectDraft(req.Draft);
|
||||
if (reject is not null) return Results.Problem(detail: reject, statusCode: StatusCodes.Status400BadRequest);
|
||||
return OrgTemplateStore.SaveDraft(subOrgId, req.Draft) is { } view ? Results.Ok(view) : Results.NotFound();
|
||||
}))
|
||||
.WithName("orgTemplatePUT")
|
||||
.Produces<OrgTemplateAdminViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
{
|
||||
var r = OrgTemplateStore.Publish(subOrgId, Now());
|
||||
if (r is not null)
|
||||
app.Logger.LogInformation("orgtemplate publish subOrg={SubOrg} version={Version} affected={Affected}",
|
||||
subOrgId, r.Version, r.AffectedUnsentBriefs);
|
||||
return r is not null ? Results.Ok(r) : Results.NotFound();
|
||||
}))
|
||||
.WithName("orgTemplatePublish")
|
||||
.Produces<PublishOrgTemplateResponse>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string subOrgId, int version, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
OrgTemplateStore.Rollback(subOrgId, version) is { } view ? Results.Ok(view) : Results.NotFound()))
|
||||
.WithName("orgTemplateRollback")
|
||||
.Produces<OrgTemplateAdminViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.Run();
|
||||
|
||||
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
|
||||
|
||||
// One gate for every org-template endpoint — the enforce twin of the
|
||||
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source).
|
||||
static IResult OrgAdmin(HttpContext ctx, Func<IResult> action) =>
|
||||
Authz.CanManageOrgTemplates(Authz.ResolvePrincipal(ctx))
|
||||
? action()
|
||||
: Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
|
||||
static string Now() => DateTimeOffset.UtcNow.ToString("o");
|
||||
|
||||
BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
|
||||
e.ToDto(),
|
||||
BriefSeed.PassagesFor(e.Beroep),
|
||||
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId));
|
||||
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId),
|
||||
// Sent letters render with the version pinned at send; everything else follows
|
||||
// the sub-org's current published template (WP-23 immutability invariant).
|
||||
OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null));
|
||||
|
||||
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
|
||||
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.
|
||||
|
||||
Reference in New Issue
Block a user