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
backend/src/BigRegister.Api/Data/Migrations/20260705085857_OrgTemplates.Designer.cs
generated
Normal file
223
backend/src/BigRegister.Api/Data/Migrations/20260705085857_OrgTemplates.Designer.cs
generated
Normal file
@@ -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")
|
||||
|
||||
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 }) },
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -902,6 +902,238 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/org-templates": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"operationId": "orgTemplates",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SubOrgSummaryDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/org-template/{subOrgId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"operationId": "orgTemplateGET",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "subOrgId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OrgTemplateAdminViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"operationId": "orgTemplatePUT",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "subOrgId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SaveOrgTemplateRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OrgTemplateAdminViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/org-template/{subOrgId}/publish": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"operationId": "orgTemplatePublish",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "subOrgId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PublishOrgTemplateResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/org-template/{subOrgId}/rollback/{version}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"operationId": "orgTemplateRollback",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "subOrgId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OrgTemplateAdminViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -1163,6 +1395,9 @@
|
||||
},
|
||||
"decisions": {
|
||||
"$ref": "#/components/schemas/BriefDecisionsDto"
|
||||
},
|
||||
"orgTemplate": {
|
||||
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1509,6 +1744,28 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MarginsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topMm": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"rightMm": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"bottomMm": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"leftMm": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MeDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1522,6 +1779,96 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"OrgTemplateAdminViewDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"draft": {
|
||||
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||
},
|
||||
"publishedVersion": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"history": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/OrgTemplateVersionDto"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"unsentBriefs": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"OrgTemplateDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subOrgId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"orgName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"returnAddress": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"logoDocumentId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"footerContact": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"footerLegal": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"signatureName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"signatureRole": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"signatureClosing": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"margins": {
|
||||
"$ref": "#/components/schemas/MarginsDto"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"OrgTemplateVersionDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"publishedAt": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"template": {
|
||||
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ParagraphDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1626,6 +1973,20 @@
|
||||
},
|
||||
"additionalProperties": { }
|
||||
},
|
||||
"PublishOrgTemplateResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"affectedUnsentBriefs": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ReferentieResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1769,6 +2130,33 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SaveOrgTemplateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"draft": {
|
||||
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SubOrgSummaryDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subOrgId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"orgName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"publishedVersion": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SubmitApplicationRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
180
backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs
Normal file
180
backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Org templates (WP-23): admin-only endpoints, draft→publish versioning, and the
|
||||
/// sent-brief immutability invariant (pin at send, republish touches unsent only).
|
||||
/// Same reset discipline as BriefEndpointTests — the stores are process-global.
|
||||
/// </summary>
|
||||
public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private const string Registers = OrgTemplateSeed.Registers;
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private HttpRequestMessage Req(HttpMethod method, string path, string? role = null, object? body = null)
|
||||
{
|
||||
var req = new HttpRequestMessage(method, path);
|
||||
if (role is not null) req.Headers.Add("X-Role", role);
|
||||
if (body is not null) req.Content = JsonContent.Create(body);
|
||||
return req;
|
||||
}
|
||||
|
||||
private async Task<OrgTemplateAdminViewDto> AdminView(string subOrgId = Registers)
|
||||
{
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{subOrgId}", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
return (await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>())!;
|
||||
}
|
||||
|
||||
private static void ResetStores()
|
||||
{
|
||||
OrgTemplateStore.Reset();
|
||||
BriefStore.Reset();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_endpoints_are_admin_only()
|
||||
{
|
||||
ResetStores();
|
||||
// drafter (no header) and approver both bounce off every admin endpoint.
|
||||
Assert.Equal(HttpStatusCode.Forbidden,
|
||||
(await _client.GetAsync("/api/v1/admin/org-templates")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden,
|
||||
(await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "approver"))).StatusCode);
|
||||
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/admin/org-templates", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var list = await res.Content.ReadFromJsonAsync<List<SubOrgSummaryDto>>();
|
||||
Assert.Equal(new[] { Registers, OrgTemplateSeed.Vakbekwaamheid }, list!.Select(s => s.SubOrgId));
|
||||
Assert.All(list!, s => Assert.Equal(1, s.PublishedVersion));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_increments_version_appends_history_and_counts_unsent_briefs()
|
||||
{
|
||||
ResetStores();
|
||||
// One unsent brief for this sub-org (GetOrCreate on first read).
|
||||
await _client.GetAsync("/api/v1/brief");
|
||||
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var published = await res.Content.ReadFromJsonAsync<PublishOrgTemplateResponse>();
|
||||
Assert.Equal(2, published!.Version);
|
||||
Assert.Equal(1, published.AffectedUnsentBriefs);
|
||||
|
||||
var view = await AdminView();
|
||||
Assert.Equal(2, view.PublishedVersion);
|
||||
Assert.Equal(new[] { 1, 2 }, view.History.Select(h => h.Version));
|
||||
Assert.Equal(1, view.UnsentBriefs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_draft_validates_margins_and_round_trips()
|
||||
{
|
||||
ResetStores();
|
||||
var draft = (await AdminView()).Draft;
|
||||
|
||||
var invalid = draft with { Margins = new MarginsDto(5, 20, 20, 25) };
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await _client.SendAsync(
|
||||
Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
||||
body: new SaveOrgTemplateRequest(invalid)))).StatusCode);
|
||||
|
||||
var valid = draft with { OrgName = "BIG-register (nieuw)", Margins = new MarginsDto(30, 20, 20, 25) };
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
||||
body: new SaveOrgTemplateRequest(valid)));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var view = await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>();
|
||||
Assert.Equal("BIG-register (nieuw)", view!.Draft.OrgName);
|
||||
Assert.Equal(30, view.Draft.Margins.TopMm);
|
||||
// Saving a draft publishes nothing.
|
||||
Assert.Equal(1, view.PublishedVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rollback_copies_an_old_version_into_the_draft_without_rewriting_history()
|
||||
{
|
||||
ResetStores();
|
||||
var draft = (await AdminView()).Draft;
|
||||
await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
||||
body: new SaveOrgTemplateRequest(draft with { OrgName = "Versie twee" })));
|
||||
await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
||||
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/rollback/1", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var view = await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>();
|
||||
Assert.Equal("BIG-register", view!.Draft.OrgName); // v1 content back in the draft
|
||||
Assert.Equal(2, view.PublishedVersion); // still live: v2 (rollback ≠ publish)
|
||||
Assert.Equal(new[] { 1, 2 }, view.History.Select(h => h.Version)); // append-only, untouched
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await _client.SendAsync(
|
||||
Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/rollback/99", role: "admin"))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sent_brief_keeps_its_pinned_template_while_an_unsent_brief_follows_a_republish()
|
||||
{
|
||||
ResetStores();
|
||||
// Walk one brief to sent under template v1.
|
||||
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||
var filled = brief.Sections
|
||||
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
|
||||
s.Required && s.Blocks.Count == 0
|
||||
? new[] { new LetterBlockDto("freeText", "b1", new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) })) }
|
||||
: s.Blocks))
|
||||
.ToList();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", new SaveBriefRequest(filled));
|
||||
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
|
||||
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver"));
|
||||
var sent = await (await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/send"))).Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
Assert.Equal(1, sent!.OrgTemplate.Version);
|
||||
|
||||
// Republish with a new org name.
|
||||
var draft = (await AdminView()).Draft;
|
||||
await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
||||
body: new SaveOrgTemplateRequest(draft with { OrgName = "Hertitelde organisatie" })));
|
||||
await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
||||
|
||||
// The sent brief still renders v1 with the old name (immutable)...
|
||||
var sentView = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||
Assert.Equal(1, sentView!.OrgTemplate.Version);
|
||||
Assert.Equal("BIG-register", sentView.OrgTemplate.OrgName);
|
||||
|
||||
// ...while a fresh (unsent) brief follows the new published version.
|
||||
var freshView = await (await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/reset"))).Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
Assert.Equal(2, freshView!.OrgTemplate.Version);
|
||||
Assert.Equal("Hertitelde organisatie", freshView.OrgTemplate.OrgName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Me_returns_the_orgtemplate_capability_for_admin()
|
||||
{
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/me", role: "admin"));
|
||||
var me = await res.Content.ReadFromJsonAsync<MeDto>();
|
||||
Assert.Equal(new[] { "orgtemplate:edit" }, me!.Capabilities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_cannot_slip_into_the_brief_review_flow()
|
||||
{
|
||||
ResetStores();
|
||||
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||
var filled = brief.Sections
|
||||
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
|
||||
s.Required && s.Blocks.Count == 0
|
||||
? new[] { new LetterBlockDto("freeText", "b1", new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) })) }
|
||||
: s.Blocks))
|
||||
.ToList();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", new SaveBriefRequest(filled));
|
||||
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
|
||||
|
||||
// Approve/reject require the Approver role explicitly — admin passes SoD
|
||||
// (different identity) but must still be Forbidden.
|
||||
Assert.Equal(HttpStatusCode.Forbidden,
|
||||
(await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "admin"))).StatusCode);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user