Compare commits

..

4 Commits

Author SHA1 Message Date
f6c837f281 feat(fp): WP-26 — admin org-template editor
Some checks failed
CI / frontend (push) Failing after 57s
CI / storybook-a11y (push) Successful in 4m27s
CI / backend (push) Successful in 1m15s
CI / codeql (csharp) (push) Failing after 1m42s
CI / codeql (javascript-typescript) (push) Failing after 1m19s
CI / api-client-drift (push) Successful in 1m40s
CI / e2e (push) Failing after 3h11m33s
Edit the letter's org identity in place on the same canvas the drafter composes
on (editableRegions='template'): letterhead/signature/footer become inline
controls, content a read-only sample. Margins (bounded), logo upload (reuses the
shared upload transport + single-upload), version history + rollback, proefbrief,
and publish-with-impact-confirmation. House form-machine idiom
(org-template.machine.ts) + root store with debounced save. Capability-gated
(orgtemplate:edit) with a deny-by-default alert; route /brief/huisstijl.

Backend + generated client were already in place (WP-23). Also fixes a
pre-existing red check:tokens (WP-24 canvas hex fallbacks) and threads the
published logo through to the drafter's canvas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:22:55 +02:00
1bb9383344 feat(fp): WP-25 — server-rendered letter HTML preview
Adds LetterHtml.Render, a pure composer mirroring the FE letter canvas'
class vocabulary, behind two ExcludeFromDescription()'d endpoints
(GET /brief/preview, GET /admin/org-template/{subOrgId}/preview).
Auto-resolvable placeholders pull from seed/case data; unresolved
manual ones render as "[NOG IN TE VULLEN: label]". A sent brief
archives its composed HTML (BriefEntity.ArchivedHtml) so a later
org-template republish never changes it. FE gets a hand-written fetch
adapter (text/html, not JSON) and a "Voorbeeld" button that opens the
preview in a new tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 12:56:36 +02:00
c07a33ee3e feat(fp): WP-24 — letter canvas (edit on the letter)
Some checks failed
CI / frontend (push) Failing after 59s
CI / storybook-a11y (push) Successful in 4m22s
CI / backend (push) Successful in 1m18s
CI / codeql (csharp) (push) Failing after 1m47s
CI / codeql (javascript-typescript) (push) Failing after 1m20s
CI / api-client-drift (push) Successful in 1m42s
CI / e2e (push) Failing after 3h8m54s
One letter surface for every role: LetterCanvasComponent renders the
org template's letterhead/signature/footer around the case-type
sections, with editableRegions content|template|none. public/letter.css
is the FE⇄BE rendering contract (WP-25 inlines it verbatim).
letter-preview deleted — its read-only rendering absorbed into 'none'
mode. brief.machine.ts byte-identical; orgTemplate parses at the
adapter boundary and lives beside the machine in BriefStore.

Also fixes passage-picker multi-select (checkboxes all shared
id="undefined", so labels only toggled the first box) and keeps the
±page-break marks from drawing through canvas content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:47:51 +02:00
5a610c10f0 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>
2026-07-05 11:17:05 +02:00
67 changed files with 11841 additions and 1154 deletions

View File

@@ -1,3 +1,5 @@
<!-- CIBG Huisstijl (customized Bootstrap 5.2), served from the vendored public/ staticDir.
Mirrors src/index.html so stories match the app. System font per styles.scss. -->
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
<!-- Letter-rendering contract (WP-24), mirrors index.html. -->
<link rel="stylesheet" href="letter.css" />

View File

@@ -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);

View File

@@ -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

View File

@@ -1,5 +1,6 @@
using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization;
using BigRegister.Domain.Letters;
using Microsoft.EntityFrameworkCore;
namespace BigRegister.Api.Data;
@@ -22,6 +23,13 @@ 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; }
/// The composed HTML archived at send (WP-25) — from here on the preview endpoint
/// serves this verbatim, so a later org-template republish never re-renders it.
public string? ArchivedHtml { get; set; }
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
}
@@ -33,6 +41,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 +111,13 @@ 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);
// Archive the composed HTML at this exact instant (WP-25): the preview endpoint
// serves this verbatim once sent, so a later republish never re-renders it.
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.SentOrgTemplateVersion);
e.ArchivedHtml = LetterHtml.Render(e, template, at, watermark: false);
db.SaveChanges();
return (Outcome.Ok, e);
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -0,0 +1,226 @@
// <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("20260705101743_ArchivedHtml")]
partial class ArchivedHtml
{
/// <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>("ArchivedHtml")
.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
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
/// <inheritdoc />
public partial class ArchivedHtml : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ArchivedHtml",
table: "Briefs",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ArchivedHtml",
table: "Briefs");
}
}
}

View File

@@ -104,6 +104,9 @@ namespace BigRegister.Api.Data.Migrations
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("ArchivedHtml")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
@@ -124,10 +127,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 +150,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")

View File

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

View File

@@ -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.

View File

@@ -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>(),
};

View File

@@ -0,0 +1,191 @@
using System.Globalization;
using System.Text;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
namespace BigRegister.Domain.Letters;
/// <summary>
/// Server-rendered letter HTML (WP-25) — the archived, "what is sent" artifact.
/// Mirrors the FE letter canvas' class vocabulary exactly (<c>public/letter.css</c>,
/// the FE⇄BE contract; LetterHtmlTests' class-parity test is the fence against drift).
///
/// Unlike the canvas, placeholders resolve to real text rather than a live editor
/// widget: an auto-resolvable key pulls from seed/case data (there is no per-brief
/// resolved value stored anywhere else in the domain — see BriefEntity's own "does
/// not interpret it" posture), an unresolved manual key renders literally as
/// "[NOG IN TE VULLEN: label]" (PRD Brief v2 §8) — the preview works despite this,
/// only send blocks on it (FE-authoritative linting).
///
/// ponytail: HTML today, a headless-Chromium PDF render slots in behind this same
/// route if the POC ever needs real PDF bytes — see the preview endpoints.
/// </summary>
public static class LetterHtml
{
private static readonly string Css = File.ReadAllText(FindLetterCss());
public static string Render(BriefEntity brief, OrgTemplateDto template, string at, bool watermark)
{
var defs = brief.Placeholders.ToDictionary(p => p.Key);
var sb = new StringBuilder();
sb.Append("<!doctype html><html lang=\"nl\"><head><meta charset=\"utf-8\">");
sb.Append("<title>").Append(Enc(brief.BriefId)).Append("</title>");
sb.Append("<style>").Append(Css).Append(ExtraCss).Append("</style>");
sb.Append("</head><body>");
sb.Append("<div class=\"letter\" style=\"").Append(MarginStyle(template.Margins)).Append("\">");
// --- letterhead ---
sb.Append("<div class=\"letter__letterhead\">");
if (template.LogoDocumentId is { } logoId && DocumentStore.Get(logoId) is { } logo)
{
sb.Append("<img class=\"org-logo\" alt=\"\" src=\"data:").Append(logo.ContentType)
.Append(";base64,").Append(Convert.ToBase64String(logo.Content)).Append("\">");
}
sb.Append("<p class=\"org-wordmark\">").Append(Enc(template.OrgName)).Append("</p>");
sb.Append("<address class=\"return-address\">").Append(EncLines(template.ReturnAddress)).Append("</address>");
sb.Append("<address class=\"address-window\">").Append(EncLines(RecipientPlaceholder)).Append("</address>");
sb.Append("<dl class=\"reference\">");
sb.Append("<div><dt>Ons kenmerk</dt><dd>").Append(Enc(brief.BriefId)).Append("</dd></div>");
sb.Append("<div><dt>Datum</dt><dd>").Append(Enc(FormatDatumNl(at))).Append("</dd></div>");
sb.Append("</dl></div>");
// --- body: the case-type template's sections ---
sb.Append("<div class=\"letter__body\">");
foreach (var section in brief.Sections)
{
sb.Append("<section><h3>").Append(Enc(section.Title)).Append("</h3>");
foreach (var block in section.Blocks)
RenderParagraphs(sb, block.Content.Paragraphs, defs);
sb.Append("</section>");
}
sb.Append("</div>");
// --- signature ---
sb.Append("<div class=\"letter__signature\">");
sb.Append("<p>").Append(Enc(template.SignatureClosing)).Append("</p>");
sb.Append("<p class=\"signature-name\">").Append(Enc(template.SignatureName)).Append("</p>");
sb.Append("<p>").Append(Enc(template.SignatureRole)).Append("</p>");
sb.Append("</div>");
// --- footer ---
sb.Append("<div class=\"letter__footer\">");
sb.Append("<div class=\"footer-contact\">").Append(EncLines(template.FooterContact)).Append("</div>");
sb.Append("<div class=\"footer-legal\">").Append(Enc(template.FooterLegal)).Append("</div>");
sb.Append("</div>");
if (watermark) sb.Append("<div class=\"preview-watermark\" aria-hidden=\"true\">VOORBEELD</div>");
sb.Append("</div></body></html>");
return sb.ToString();
}
/// Exposed so LetterHtmlTests can assert every `letter`-prefixed class this
/// renderer emits also exists in the shared contract file — the fence against drift.
public static string StyleSheet => Css;
// No recipient address is tracked anywhere in this POC's brief domain (BRP lookup
// is out of scope here) — the canvas shows the same static placeholder text.
private const string RecipientPlaceholder = "Adres van de geadresseerde\n(wordt ingevuld bij verzending)";
private static void RenderParagraphs(
StringBuilder sb, IReadOnlyList<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> defs)
{
string? openList = null;
foreach (var para in paragraphs)
{
if (para.List != openList)
{
if (openList is not null) sb.Append(openList == "bullet" ? "</ul>" : "</ol>");
if (para.List is not null) sb.Append(para.List == "bullet" ? "<ul>" : "<ol>");
openList = para.List;
}
sb.Append(openList is null ? "<p>" : "<li>");
foreach (var node in para.Nodes) RenderNode(sb, node, defs);
sb.Append(openList is null ? "</p>" : "</li>");
}
if (openList is not null) sb.Append(openList == "bullet" ? "</ul>" : "</ol>");
}
private static void RenderNode(
StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> defs)
{
switch (node.Type)
{
case "text":
sb.Append(Enc(node.Text ?? ""));
break;
case "lineBreak":
sb.Append("<br>");
break;
case "placeholder":
var key = node.Key ?? "";
var def = defs.GetValueOrDefault(key);
var label = def?.Label ?? key;
sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label)) : Enc($"[NOG IN TE VULLEN: {label}]"));
break;
}
}
// The only place a placeholder key gets a real value: seed/case data for the
// single demo applicant (SeedData.Registration — no per-brief resolved value is
// ever stored, see the class doc above). Falls back to the label itself for any
// other auto-resolvable key, mirroring the FE canvas' own `sampleFor` fallback.
private static string ResolveAuto(string key, string label) => key switch
{
"naam_zorgverlener" => SeedData.Registration.Naam,
"big_nummer" => SeedData.Registration.BigNummer,
"datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o")),
_ => label,
};
private static readonly CultureInfo Nl = CultureInfo.GetCultureInfo("nl-NL");
private static string FormatDatumNl(string at) => DateTimeOffset.Parse(at).ToString("d MMMM yyyy", Nl);
private static string MarginStyle(MarginsDto m) =>
$"--letter-margin-top:{m.TopMm}mm;--letter-margin-right:{m.RightMm}mm;" +
$"--letter-margin-bottom:{m.BottomMm}mm;--letter-margin-left:{m.LeftMm}mm;";
private static string Enc(string s) => System.Net.WebUtility.HtmlEncode(s);
private static string EncLines(string s) => Enc(s).Replace("\n", "<br>");
// Walks up from the running assembly's own directory (NOT the process cwd, which
// varies by how `dotnet run`/docker/tests invoke it — see docs/backlog/WP-25) until
// it finds `public/letter.css`. docker-compose.yml bind-mounts `./public` under the
// api container's `/src` for exactly this walk to resolve there too.
private static string FindLetterCss()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "public", "letter.css");
if (File.Exists(candidate)) return candidate;
}
throw new FileNotFoundException(
$"public/letter.css not found by walking up from {AppContext.BaseDirectory} " +
"— check the docker bind mount or build output location.");
}
// Backend-only concerns absent from the FE canvas (no live preview toggle for
// either): kept out of the shared contract file, not "letter"-prefixed so the
// class-parity test's scope doesn't need to widen for them.
private const string ExtraCss = """
.preview-watermark {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 72pt;
font-weight: 700;
color: rgb(200 30 30 / 0.18);
transform: rotate(-30deg);
pointer-events: none;
z-index: 3;
}
.org-logo {
display: block;
max-height: 18mm;
margin-block-end: 4mm;
}
""";
}

View File

@@ -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;
}
}

View File

@@ -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;
@@ -345,6 +346,31 @@ api.MapPost("/brief/send", (HttpContext ctx) =>
.Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status409Conflict);
// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch →
// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent
// letters serve their frozen archive; anything else renders live with a watermark.
api.MapGet("/brief/preview", (HttpContext ctx) =>
{
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
return Results.Content(archived, "text/html");
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html");
})
.ExcludeFromDescription();
// Proefbrief: the admin's unpublished draft template rendered over a fixture
// brief, so the appearance can be checked before publishing touches real letters.
api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
{
var view = OrgTemplateStore.AdminView(subOrgId);
if (view is null) return Results.NotFound();
var fixture = BriefSeed.NewBrief("proefbrief");
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
}))
.ExcludeFromDescription();
api.MapPost("/brief/reset", (HttpContext ctx) =>
{
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
@@ -354,16 +380,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.

View File

@@ -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": {

View File

@@ -23,4 +23,10 @@
<ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="LetterHtml.golden.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,215 @@
<!doctype html><html lang="nl"><head><meta charset="utf-8"><title>golden-brief-1</title><style>/* letter.css — the FE⇄BE letter-rendering CONTRACT (WP-24/WP-25).
*
* One stylesheet, two consumers: the FE letter canvas loads it via <link>
* (index.html + Storybook preview-head), the backend HTML renderer (WP-25)
* inlines this same file. Its class-parity test is the fence against drift.
*
* Class vocabulary: .letter, .letter__letterhead, .letter__body,
* .letter__signature, .letter__footer, .letter__page-break.
* Margins arrive as --letter-margin-* custom props (mm, from the OrgTemplate);
* the defaults below match the seed template.
*
* Deliberately self-contained: no --rhc- or --bs- tokens — the backend renderer
* has no token bridge. Geometry follows the sample voorbeeldbrief-inschrijving
* (A4, return address above the envelope window, reference block, footer rule).
*/
.letter {
--letter-margin-top: 25mm;
--letter-margin-right: 25mm;
--letter-margin-bottom: 25mm;
--letter-margin-left: 25mm;
position: relative;
box-sizing: border-box;
width: 210mm; /* A4 */
max-width: 100%;
min-height: 297mm;
margin-inline: auto;
padding: var(--letter-margin-top) var(--letter-margin-right) var(--letter-margin-bottom)
var(--letter-margin-left);
background: #fff;
color: #1a1a1a;
/* Letter typography is the letter's, not the portal UI's. Licensed RO/Rijks
fonts are not shipped; system stack mirrors the app-wide decision (ADR-0003). */
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
font-size: 10.5pt;
line-height: 1.5;
display: flex;
flex-direction: column;
}
.letter p {
margin: 0 0 0.75em;
}
.letter ul,
.letter ol {
margin: 0 0 0.75em;
padding-inline-start: 1.4em;
}
/* --- Letterhead: wordmark, return address above the envelope window, reference block --- */
.letter__letterhead {
margin-block-end: 12mm;
}
.letter__letterhead .org-wordmark {
font-size: 13pt;
font-weight: 700;
margin: 0 0 10mm;
}
.letter__letterhead .return-address {
font-style: normal;
font-size: 7.5pt;
white-space: pre-line;
color: #555;
margin-block-end: 2mm;
}
/* Envelope-window position (~C5 venstercouvert): recipient block. */
.letter__letterhead .address-window {
min-height: 22mm;
font-style: normal;
white-space: pre-line;
margin-block-end: 8mm;
}
.letter__letterhead .reference {
display: flex;
gap: 10mm;
font-size: 8.5pt;
color: #333;
}
.letter__letterhead .reference dt {
font-weight: 700;
margin: 0;
}
.letter__letterhead .reference dd {
margin: 0;
}
/* --- Body: the case-type template's sections --- */
.letter__body {
flex: 1;
/* Above the absolutely-positioned page-break marks: the dashed line stays visible
in the gaps but never draws THROUGH opaque content (editors, pickers). */
position: relative;
z-index: 1;
}
.letter__body h3 {
font-size: inherit;
font-weight: 700;
margin: 0 0 0.25em;
}
.letter__body section {
margin-block-end: 1.5em;
}
/* --- Signature --- */
.letter__signature {
margin-block-start: 10mm;
}
.letter__signature p {
margin: 0;
}
.letter__signature .signature-name {
margin-block-start: 3em; /* room for the (not-shipped) handwritten signature */
font-weight: 700;
}
/* --- Footer: contact + legal, above a rule --- */
.letter__footer {
margin-block-start: 10mm;
padding-block-start: 3mm;
border-block-start: 0.5pt solid #999;
font-size: 7.5pt;
color: #555;
display: flex;
justify-content: space-between;
gap: 10mm;
}
.letter__footer .footer-contact {
white-space: pre-line;
}
.letter__footer .footer-legal {
text-align: end;
}
/* --- Approximate page-break indicator (screen only; PRD §2b honesty rule) ---
Positioned per A4-interval by the canvas; the print preview is authoritative. */
.letter__page-break {
position: absolute;
inset-inline: 0;
border-block-start: 1px dashed #b36200;
pointer-events: none;
}
.letter__page-break > span {
position: absolute;
inset-inline-end: 2mm;
inset-block-start: 0;
transform: translateY(-50%);
/* Above .letter__body: the honesty caption stays legible even over content. */
z-index: 2;
font-size: 7pt;
color: #b36200;
background: #fff;
padding-inline: 1mm;
white-space: nowrap;
}
/* --- Print: real pages, no indicator chrome --- */
@page {
size: A4;
margin: 0;
}
@media print {
.letter {
width: auto;
min-height: 0;
}
.letter__page-break {
display: none;
}
}
.preview-watermark {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 72pt;
font-weight: 700;
color: rgb(200 30 30 / 0.18);
transform: rotate(-30deg);
pointer-events: none;
z-index: 3;
}
.org-logo {
display: block;
max-height: 18mm;
margin-block-end: 4mm;
}</style></head><body><div class="letter" style="--letter-margin-top:25mm;--letter-margin-right:20mm;--letter-margin-bottom:20mm;--letter-margin-left:25mm;"><div class="letter__letterhead"><p class="org-wordmark">BIG-register</p><address class="return-address">Retouradres: Postbus 00000, 2500 AA Den Haag</address><address class="address-window">Adres van de geadresseerde<br>(wordt ingevuld bij verzending)</address><dl class="reference"><div><dt>Ons kenmerk</dt><dd>golden-brief-1</dd></div><div><dt>Datum</dt><dd>5 juli 2026</dd></div></dl></div><div class="letter__body"><section><h3>Aanhef</h3><p>Geachte heer/mevrouw Dr. A. (Anna) de Vries,</p></section><section><h3>Kern van het besluit</h3><p>Op </p><ul><li>Eerste punt: [NOG IN TE VULLEN: Reden besluit]</li><li>Tweede punt</li></ul></section><section><h3>Slot</h3><p>Met vriendelijke groet,</p></section></div><div class="letter__signature"><p>Met vriendelijke groet,</p><p class="signature-name">A. de Vries</p><p>Hoofd Registratie, BIG-register</p></div><div class="letter__footer"><div class="footer-contact">BIG-register &#183; Postbus 00000, 2500 AA Den Haag &#183; 070 000 00 00 &#183; info@voorbeeld.example</div><div class="footer-legal">Dit is een gegenereerd voorbeelddocument uit de register-reference PoC. Alle gegevens zijn fictief.</div></div><div class="preview-watermark" aria-hidden="true">VOORBEELD</div></div></body></html>

View File

@@ -0,0 +1,102 @@
using System.Text.RegularExpressions;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Letters;
namespace BigRegister.Tests;
/// <summary>
/// WP-25's fence against drift between the backend renderer and the FE letter
/// canvas: a golden-file snapshot of a fixed brief + template, and a class-parity
/// check that every `letter`-prefixed class the renderer emits exists in the
/// shared `public/letter.css` contract. Neither test launches a browser.
/// </summary>
public class LetterHtmlTests
{
private static BriefEntity FixtureBrief() => new()
{
BriefId = "golden-brief-1",
Owner = "golden",
Beroep = "arts",
TemplateId = "besluit-arts",
DrafterId = BriefStore.DrafterId,
Placeholders = new[]
{
new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true),
new PlaceholderDefDto("datum", "Datum", true),
new PlaceholderDefDto("reden_besluit", "Reden besluit", false),
},
Sections = new()
{
new("aanhef", "Aanhef", true, new List<LetterBlockDto>
{
new("freeText", "aanhef-1", new RichTextBlockDto(new[]
{
new ParagraphDto(new[]
{
new RichTextNodeDto("text", Text: "Geachte heer/mevrouw "),
new RichTextNodeDto("placeholder", Key: "naam_zorgverlener"),
new RichTextNodeDto("text", Text: ","),
}),
})),
}, Locked: true),
new("kern", "Kern van het besluit", true, new List<LetterBlockDto>
{
new("freeText", "kern-1", new RichTextBlockDto(new[]
{
new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "Op ") }),
new ParagraphDto(new RichTextNodeDto[]
{
new("text", Text: "Eerste punt: "),
new("placeholder", Key: "reden_besluit"),
}, List: "bullet"),
new ParagraphDto(new RichTextNodeDto[]
{
new("text", Text: "Tweede punt"),
}, List: "bullet"),
})),
}),
new("slot", "Slot", false, new List<LetterBlockDto>
{
new("freeText", "slot-1", new RichTextBlockDto(new[]
{
new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "Met vriendelijke groet,") }),
})),
}, Locked: true),
},
Status = new BriefStatusDto("draft"),
};
private static readonly OrgTemplateDto Template = new(
"cibg-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), Version: 1);
private const string At = "2026-07-05T12:00:00.0000000+00:00";
private static readonly string GoldenPath = Path.Combine(AppContext.BaseDirectory, "LetterHtml.golden.html");
[Fact]
public void Render_matches_the_golden_file()
{
var html = LetterHtml.Render(FixtureBrief(), Template, At, watermark: true);
var golden = File.ReadAllText(GoldenPath);
Assert.Equal(golden, html);
}
[Fact]
public void Every_letter_prefixed_class_exists_in_letter_css()
{
var html = LetterHtml.Render(FixtureBrief(), Template, At, watermark: true);
var classes = Regex.Matches(html, "class=\"([^\"]+)\"")
.SelectMany(m => m.Groups[1].Value.Split(' '))
.Where(c => c.StartsWith("letter"))
.Distinct();
Assert.NotEmpty(classes);
Assert.All(classes, c => Assert.Contains($".{c}", LetterHtml.StyleSheet));
}
}

View 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);
}
}

View File

@@ -0,0 +1,96 @@
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>
/// WP-25: the two HTML preview endpoints. Both are excluded from the OpenAPI doc
/// (see the drift check in the API-client generation step) — these tests hit them
/// as plain HTTP, the same way the hand-written FE fetch does.
/// </summary>
public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private const string Registers = OrgTemplateSeed.Registers;
private readonly HttpClient _client = factory.CreateClient();
private static LetterBlockDto FreeText(string id) =>
new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) }));
private static SaveBriefRequest FilledFrom(BriefDto brief)
{
var i = 0;
var sections = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks))
.ToList();
return new SaveBriefRequest(sections);
}
private static void ResetStores()
{
BriefStore.Reset();
OrgTemplateStore.Reset();
}
private HttpRequestMessage Req(HttpMethod method, string path, string? role = null) =>
role is null ? new HttpRequestMessage(method, path) : new HttpRequestMessage(method, path) { Headers = { { "X-Role", role } } };
[Fact]
public async Task Preview_of_an_unsent_brief_renders_live_with_a_watermark()
{
ResetStores();
await _client.GetAsync("/api/v1/brief"); // GetOrCreate the demo draft
var res = await _client.GetAsync("/api/v1/brief/preview");
res.EnsureSuccessStatusCode();
Assert.Equal("text/html", res.Content.Headers.ContentType?.MediaType);
var html = await res.Content.ReadAsStringAsync();
Assert.Contains("<div class=\"preview-watermark\"", html);
}
[Fact]
public async Task Preview_of_a_sent_brief_serves_the_archive_unchanged_after_a_republish()
{
ResetStores();
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver"));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/send"));
var sentHtml = await (await _client.GetAsync("/api/v1/brief/preview")).Content.ReadAsStringAsync();
Assert.DoesNotContain("<div class=\"preview-watermark\"", sentHtml);
Assert.Contains("BIG-register", sentHtml);
// Republish the org template under a new name.
var adminView = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{Registers}", role: "admin"));
var draft = (await adminView.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>())!.Draft;
await _client.SendAsync(new HttpRequestMessage(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}")
{
Headers = { { "X-Role", "admin" } },
Content = JsonContent.Create(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's preview is unchanged — still the archived rendering.
var afterRepublish = await (await _client.GetAsync("/api/v1/brief/preview")).Content.ReadAsStringAsync();
Assert.Equal(sentHtml, afterRepublish);
Assert.DoesNotContain("Hertitelde organisatie", afterRepublish);
}
[Fact]
public async Task Proefbrief_is_admin_only_and_renders_the_draft_template()
{
ResetStores();
Assert.Equal(HttpStatusCode.Forbidden,
(await _client.GetAsync($"/api/v1/admin/org-template/{Registers}/preview")).StatusCode);
var res = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{Registers}/preview", role: "admin"));
res.EnsureSuccessStatusCode();
var html = await res.Content.ReadAsStringAsync();
Assert.Contains("BIG-register", html);
Assert.Contains("<div class=\"preview-watermark\"", html);
}
}

View File

@@ -16,6 +16,11 @@ services:
- ./backend:/src:z
- api-bin:/src/src/BigRegister.Api/bin
- api-obj:/src/src/BigRegister.Api/obj
# WP-25: LetterHtml.Render inlines public/letter.css (the FE⇄BE letter
# contract, repo-root sibling of backend/) — mounted here so the same
# walk-up-from-the-assembly lookup that works for `dotnet run`/tests also
# resolves inside this container, whose bind mount otherwise only sees backend/.
- ./public:/src/public:z
ports:
- '5000:5000'

View File

@@ -30,8 +30,9 @@ From WP-01 onward, additionally:
npm run test-storybook:ci
```
Backend stays untouched throughout (frontend-only backlog); `cd backend && dotnet test`
only needs re-running if a WP unexpectedly touches `backend/`.
Phases 05 were frontend-only; **phase 6 (Brief v2) touches `backend/`** — for those
WPs `cd backend && dotnet test` is part of GREEN, and any wire change ends with
`npm run gen:api` leaving no drift.
From WP-19 onward, `npm run e2e` is part of CI (its own job) but NOT part of the local
GREEN one-liner above — it needs the real backend + `npm start` already running (see
@@ -63,9 +64,15 @@ for its existing violations, so every WP ends green.
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | todo |
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | todo |
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | todo |
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo |
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo |
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |
Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn);
03/04 before 0509 (boundaries stop new violations during refactors); 06 before 07 (typed
@@ -75,6 +82,9 @@ are independent of each other and of phases 14 — pick any order; **18 is th
first pick** (it's the headline gap: no authorization spine exists yet, and it closes the
FE-computed-authz anti-pattern in `brief.store.ts`). 22 is explicitly lower priority — the
current in-memory persistence is a documented, defensible POC choice, not a bug.
Phase 6 (Brief v2, the "Brief opstellen v2" PRD) is strictly ordered
23 → 24 → 25 → 26 → 27 → 28: 24 needs 23's `orgTemplate` on the wire, 25 needs 24's
`letter.css` contract, 26 needs 23's endpoints + 24's canvas, 27/28 polish on top.
## WP template

View File

@@ -1,6 +1,6 @@
# WP-20 — Second locale proof
Status: done (pending commit)
Status: done (e276629)
Phase: 5 — productie-volwassenheid
## Why

View File

@@ -1,6 +1,6 @@
# WP-21 — Resilience seams (correlation-id, idempotency, retry)
Status: done (pending commit)
Status: done (40dbcb2)
Phase: 5 — productie-volwassenheid
## Why

View File

@@ -1,6 +1,6 @@
# WP-22 — Durable persistence (optional tier)
Status: done (pending commit)
Status: done (556f2f4)
Phase: 5 — productie-volwassenheid
## Why

View File

@@ -0,0 +1,114 @@
# WP-23 — Org-template backend + admin role
Status: done
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
PRD "Brief opstellen v2" splits a rendered letter over two orthogonal template axes:
the **case-type template** (section structure + placeholders — exists, unchanged) and
a new **organization template** (appearance/identity per sub-organization: letterhead,
footer, signature, margins). This WP builds the second axis server-side plus the
`admin` role that will edit it (WP-26). Everything downstream (canvas WP-24, preview
WP-25, editor WP-26) reads what this WP serves.
## Read first
- `docs/prd` — the Brief v2 PRD §2a/§3 (two axes, OrgTemplate model, invariants)
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (store idiom + `BriefSeed`)
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs` (emit+enforce single source)
- `backend/src/BigRegister.Api/Data/AppDbContext.cs` (JSON-column precedent, WP-22)
- `docs/backlog/WP-18-abac-capability-spine.md` (how the capability spine works)
## Decisions (pre-made, don't relitigate)
- **No case model, no sub-org auth scoping.** `GET /brief` stays; the brief just
gains a `SubOrgId`. Two seeded sub-orgs (`cibg-registers`, `cibg-vakbekwaamheid`)
exist purely so the admin editor can demo isolation.
- **One row per sub-org**, versions as a JSON history column
(`OrgTemplateEntity { SubOrgId PK, Draft json, PublishedVersion, History json }`) —
the WP-22 JSON-column precedent; no extra tables. Publish = append draft snapshot
to history + version++. Rollback = copy `History[v]` into `Draft` (history stays
append-only; admin republishes).
- **Sent letters are immutable**: `Send` pins `SentOrgTemplateVersion`; a sent
brief's `BriefViewDto.orgTemplate` resolves from history, never from the current
published version. Unsent briefs always follow the current published version —
that is the point of the admin editor.
- **Admin = third `X-Role` value** (`PrincipalRole.Admin`), capability
`orgtemplate:edit` via `GET /me`. Approve/reject gain an explicit
`Role == Approver` condition so the new role cannot slip through the SoD-only
check. The existing `X-Admin` document-deletion seam stays untouched.
- **Trimmed model** (PRD §3 minus): no signature image asset, no structured address
objects, no per-template fonts. Return address / footer contact are multiline
strings. Margins are 4 bounded ints (mm, 1050) — server-validated.
- **Logo = existing upload machinery**: seed an `org-logo` category (png/jpeg, 1 MB)
under a `org-template` wizardId; the template stores only `logoDocumentId`.
- Seed values come from the sample artifact `voorbeeldbrief-inschrijving.pdf`
(A. de Vries / Hoofd Registratie / Postbus 00000 / info@voorbeeld.example — all fictitious).
## Files
- `backend/src/BigRegister.Api/Contracts/Dtos.cs``MarginsDto`, `OrgTemplateDto`,
`OrgTemplateVersionDto`, `OrgTemplateAdminViewDto`, `SaveOrgTemplateRequest`,
`PublishOrgTemplateResponse`, `SubOrgSummaryDto`; `BriefViewDto` + `OrgTemplate`.
- `backend/src/BigRegister.Api/Data/OrgTemplateStore.cs` (new) — entity + store + seed.
- `backend/src/BigRegister.Api/Data/AppDbContext.cs` — OrgTemplates DbSet + JSON converters.
- `backend/src/BigRegister.Api/Data/Migrations/*` — new migration.
- `backend/src/BigRegister.Api/Data/BriefStore.cs``SubOrgId`, `SentOrgTemplateVersion`, pin at `Send`.
- `backend/src/BigRegister.Api/Domain/Authorization/Authz.cs``Admin` role, capability, gate.
- `backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs``org-logo` category.
- `backend/src/BigRegister.Api/Program.cs` — 5 admin endpoints, `ToView` orgTemplate resolution.
- `backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs` (new).
- Regenerated: `backend/swagger.json`, `src/app/shared/infrastructure/api-client.ts`.
- FE seam only: `src/app/shared/domain/role.ts`, `shared/domain/capability.ts`,
`shared/infrastructure/role.ts`, `shared/infrastructure/role.interceptor.ts`.
## Steps
1. DTOs (above).
2. `OrgTemplateEntity` + `OrgTemplateStore` (list/get/saveDraft/publish/rollback/
published/versionPayload; margin validation; seed-on-first-access, 2 sub-orgs,
draft == published v1) + AppDbContext mapping + migration.
3. `PrincipalRole.Admin`; `ResolvePrincipal` reads `admin`; `RoleCapabilities(Admin)`
`orgtemplate:edit`; approve/reject checks require `Approver` explicitly.
4. Endpoints: `GET /admin/org-templates`, `GET|PUT /admin/org-template/{subOrgId}`,
`POST …/publish` (returns impact count = unsent briefs of that sub-org),
`POST …/rollback/{version}`. All 403 for non-admin via `Authz`.
5. `BriefEntity.SubOrgId` (seed `cibg-registers`) + `SentOrgTemplateVersion`; `Send`
pins; `ToView` resolves published-vs-pinned into `BriefViewDto.orgTemplate`.
6. Seed `org-logo` upload category.
7. `npm run gen:api`.
8. FE: widen `Role`/`Capability` unions, `currentRole()`, interceptor URL filter
(nothing consumes them yet — WP-24/26 do).
## Acceptance criteria
- [x] Publish increments `publishedVersion` and appends to history; rollback copies an
old version into the draft without rewriting history.
- [x] Every admin endpoint returns 403 for drafter/approver, 200 for `X-Role: admin`.
- [x] A sent brief keeps its pinned org-template version after a republish; an unsent
brief follows the new published version (both asserted in one test).
- [x] Publish impact count = number of unsent briefs of that sub-org.
- [x] `PUT` with out-of-bounds margins → 400.
- [x] `GET /brief` carries `orgTemplate`; existing brief tests stay green.
- [x] `GET /me` with `X-Role: admin``["orgtemplate:edit"]`.
- [x] Full GREEN (FE untouched functionally, but lint/test/build/storybook all pass).
## Verification
`cd backend && dotnet test`; GREEN one-liner; curl smoke: admin list/save/publish
(200) vs drafter (403); sent-brief pin walk-through per acceptance.
## Out of scope
The canvas (WP-24), HTML preview endpoints + archive-at-send (WP-25), the admin UI
(WP-26). Template approval chains (draft→publish is enough for the POC; flagged as
an open question in the PRD). Sub-org-scoped brief authorization.
## Risks
`BriefViewDto` gains a field — additive, but the FE `parseBriefView` boundary and
generated client must be regenerated in the same WP to keep the drift check green.
Adding `PrincipalRole.Admin` touches the approve/reject SoD path: the explicit
`Role == Approver` condition must preserve today's Forbidden-before-Conflict order
(existing tests prove it).

View File

@@ -0,0 +1,99 @@
# WP-24 — Letter canvas (edit on the letter)
Status: done
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
The drafter should compose **on the letter** — letterhead above, footer/signature
below, content blocks edited in place — instead of in an abstract form next to a
separate preview. PRD Brief v2 §4. This is a **presentation rebuild only**: the
domain model, `brief.machine.ts`, and every `BriefMsg` stay byte-identical.
## Read first
- PRD Brief v2 §2b (fidelity note), §4, §10; the sample `voorbeeldbrief-inschrijving.pdf`
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (the `canEdit` pivot)
- `src/app/brief/ui/letter-preview/letter-preview.component.ts` (rendering that migrates in)
- `docs/backlog/WP-23-org-template-backend.md` (the `orgTemplate` on `BriefViewDto`)
## Decisions (pre-made, don't relitigate)
- **One stylesheet is the FE⇄BE rendering contract**: `public/letter.css` — class
vocabulary `.letter`, `.letter__letterhead`, `.letter__body`, `.letter__signature`,
`.letter__footer`, `.letter__page-break`; margins as `--letter-margin-*` custom
props; `@page`/print rules. Loaded via `<link>` in `index.html` (app + Storybook).
WP-25's backend renderer inlines the same file; its parity test is the fence.
- **`LetterCanvasComponent`** (new organism, `Domein/Brief/Letter Canvas`) with
`editableRegions: 'content' | 'template' | 'none'` — one component serves drafter
composing, approver review (and in WP-26, the admin editor). Letter typography on
the canvas is the letter's, not the portal UI's — but still token-bridged.
- `'content'` mode hosts the **existing** `letter-section`/`letter-block` components
unchanged; letterhead/footer/signature render read-only with a subtle tint and a
first-use caption ("komt uit de huisstijl van de organisatie").
- `'none'` mode absorbs `letter-preview`'s rendering (paragraph grouping, placeholder
chips, sample-values toggle); **`ui/letter-preview/` is then deleted** — the PRD
explicitly supersedes it; no third rendering is maintained.
- **Page-break indicator is approximate by design**: a dashed line per A4-content
interval with the caption "±pagina-einde — afdrukvoorbeeld is leidend" (PRD §2b
honesty requirement).
- `brief.machine.ts` and `BriefMsg` are untouched — `git diff` must prove it.
## Files
- `public/letter.css` (new), `src/index.html` (link)
- `src/app/brief/domain/org-template.ts` (new, pure) — `OrgTemplate` type
- `src/app/brief/infrastructure/brief.adapter.ts` (+spec) — parse `orgTemplate`
- `src/app/brief/ui/letter-canvas/*` (new: component + stories)
- `src/app/brief/ui/letter-composer/letter-composer.component.ts` (pivot swap) + stories
- `src/app/brief/ui/brief.page.ts` (pass orgTemplate through)
- delete `src/app/brief/ui/letter-preview/*`
## Steps
1. `letter.css` from the sample PDF's geometry (A4 proportions, letterhead, address
window + reference block, footer rule, signature).
2. `OrgTemplate` domain type + `parseOrgTemplate` boundary in the adapter (+spec).
3. Canvas organism: three modes, zoom input, page-break indicator.
4. Migrate `letter-preview` rendering into `'none'` mode; delete the component,
fold its stories into the canvas stories.
5. Swap the composer's `@if (canEdit())` pivot for
`<app-letter-canvas [editableRegions]="canEdit() ? 'content' : 'none'">`.
6. Stories: three modes + zoom + page-break, all axe-gated.
## Acceptance criteria
- [x] Drafter edits blocks in place on the letter surface; passage picker and
diagnostics click-to-locate still work on the canvas.
- [x] Approver sees the identical surface read-only with the action bar.
- [x] Letterhead/footer/signature come from `orgTemplate` and are visibly non-editable.
- [x] `git diff` shows zero changes in `brief.machine.ts` / `brief.ts` Msg surface.
- [x] `letter-preview` is gone; no story regression (`test-storybook:ci` green).
- [x] Full GREEN + e2e smoke.
Field notes (landed alongside): the CIBG huisstijl styles bare `<header>`/`<footer>`
elements (robijn background), so the canvas regions are `<div>`s — the letter surface
must stay letter.css-only. The passage picker's checkboxes now get unique
`checkboxId`s (all previously resolved to `id="undefined"`, so labels toggled only
the first box — multi-select was broken) and an opaque background; `.letter__body`
stacks above the page-break marks so the dashed line never draws through content,
while the "±pagina-einde" caption floats above everything for legibility.
## Verification
GREEN one-liner; `npm run e2e` (brief flow); manual: `?role=drafter` compose on
canvas, `?role=approver` review; `?scenario=slow|error` still degrade gracefully
through `<app-async>`.
## Out of scope
Server-rendered preview + `[NOG IN TE VULLEN]` resolution (WP-25); admin `'template'`
mode wiring beyond the input existing (WP-26 gives it a consumer); zoom controls
polish + standaardbrief + diff badges (WP-27).
## Risks
The canvas duplicates the letter markup that WP-25's backend renderer will emit —
acceptable *only* because `letter.css` is shared and WP-25 adds the class-parity
test; until WP-25 lands, the canvas is the sole consumer, so no drift is possible.
Deleting `letter-preview` breaks any deep import of it — repo-wide grep before delete.

View File

@@ -0,0 +1,90 @@
# WP-25 — Server-rendered letter preview (HTML; PDF seam deferred)
Status: done
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
"What you compose is what is sent" needs a server-side rendering of the letter —
placeholders resolved, org template applied — from the same CSS contract the canvas
uses (PRD §2b: one rendering, used twice). The preview is the artifact: at send, the
same composition is archived with the brief, making sent letters immutable.
## Read first
- PRD Brief v2 §2b, §8; `docs/backlog/WP-24-letter-canvas.md` (the `letter.css` contract)
- `backend/src/BigRegister.Api/Program.cs` — upload `content` endpoint (binary house
pattern: `.ExcludeFromDescription()` + hand-written FE fetch)
- `src/app/shared/upload/upload.adapter.ts` (hand-written transport precedent)
## Decisions (pre-made, don't relitigate)
- **HTML, not PDF** (user decision at plan review): no Microsoft.Playwright/Chromium
dependency in the POC. `GET /api/v1/brief/preview` returns `text/html` — the fully
composed, print-ready letter (`@page` CSS; browser print-to-PDF is the manual
affordance). The endpoint is the seam where a headless-Chromium PDF render slots
in later; mark it `// ponytail: HTML today, Chromium PDF behind this same route if
the POC ever needs real PDF bytes`.
- **`LetterHtml.Render(brief, orgTemplate)`** is a pure static composer: mirrors the
canvas class vocabulary exactly, inlines `public/letter.css` from disk, inlines the
logo bytes as a data-URI. Placeholders: auto-resolvable keys resolve from
seed/case data; unresolved manual keys render as `[NOG IN TE VULLEN: label]`
(PRD §8) — preview is allowed with errors, only send blocks on them.
- **Parity is tested, not hoped for**: a golden-file test snapshots the composed
HTML; a second test asserts every `letter`-prefixed class in the golden HTML
exists in `letter.css`. `dotnet test` never launches a browser.
- **Archive at send**: `Send` stores the composed HTML in `BriefEntity.ArchivedHtml`
(SQLite text column) alongside the WP-23 version pin; the preview endpoint serves
the archive when status is `sent`, so a republish never changes a sent letter.
- **Two endpoints, both excluded from OpenAPI** (JSON-only generated client stays
clean): `GET /brief/preview` and `GET /admin/org-template/{subOrgId}/preview`
(proefbrief: draft template + a fixture brief). FE consumes them via a small
hand-written fetch (needs the `X-Role` header) → blob → object URL in a new tab.
- Watermark: previews of unsent letters carry a `VOORBEELD` watermark (CSS), the
archived/sent rendering never does — the PRD's open question resolved the simple way.
## Files
- `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` (new)
- `backend/src/BigRegister.Api/Data/BriefStore.cs``ArchivedHtml` + archive at send (+migration)
- `backend/src/BigRegister.Api/Program.cs` — 2 preview endpoints
- `backend/tests/BigRegister.Tests/LetterHtmlTests.cs` (new) + `LetterHtml.golden.html`
- `src/app/brief/infrastructure/letter-preview.adapter.ts` (new, fetch → `Result<string, Blob>`)
- `src/app/brief/application/brief.store.ts``previewLetter()` command
- `src/app/brief/ui/letter-composer/*` — "Voorbeeld" button
## Steps
1. `LetterHtml.Render` + placeholder resolution + data-URI logo + watermark flag.
2. Golden-file + class-parity tests.
3. Endpoints (serve archive when sent; proefbrief renders the draft template).
4. Archive-at-send in `BriefStore.Send` (+ migration for `ArchivedHtml`).
5. FE adapter + store command + button (explicit action — no live re-render; PRD §8).
## Acceptance criteria
- [x] Preview opens the composed print-ready letter in a new tab; browser print
shows correct margins via `@page`.
- [x] Unresolved manual placeholders render `[NOG IN TE VULLEN: …]`; preview works
despite lint errors (only send blocks).
- [x] A sent brief serves its archived HTML unchanged after an org-template republish.
- [x] Golden + parity tests green without any browser installed.
- [x] `swagger.json` unchanged by the two endpoints (drift check green).
## Verification
`cd backend && dotnet test`; GREEN one-liner; manual: compose → preview → print
dialog; send → republish template → preview still the archived rendering.
## Out of scope
Real PDF bytes / headless Chromium (the deliberate deferral — the endpoint is the
seam). Pixel-parity testing (the shared CSS + class-parity test is the fence).
Pagination fidelity beyond the browser's own print engine.
## Risks
`LetterHtml` reads `public/letter.css` from disk — path must resolve for `dotnet run`,
tests, and docker (bind mount/copy); fail loudly with a clear error if missing.
The golden file will churn whenever the letter structure changes — that is its job;
update it deliberately, never blindly.

View File

@@ -0,0 +1,118 @@
# WP-26 — Admin org-template editor
Status: done
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
The org template (WP-23) needs its editor: an admin edits, per sub-organization, the
letter's appearance **in place on the same canvas** the drafter composes on — the
mirror image (`editableRegions='template'`: letterhead/footer/signature editable,
content a read-only sample). PRD Brief v2 §5, §7h.
## Read first
- PRD Brief v2 §5, §7h; `docs/backlog/WP-23/24/25` (endpoints, canvas, proefbrief)
- `src/app/shared/application/access.store.ts` (`can('orgtemplate:edit')`)
- `.claude/skills/form-machine` — the house form idiom this editor follows
- `src/app/shared/ui/upload/single-upload` (logo upload reuse)
## Decisions (pre-made, don't relitigate)
- **Lives in the `brief` context** (route `/brief/huisstijl`, lazy) — same bounded
capability, no new context. Gated by `AccessStore.can('orgtemplate:edit')` with a
denial alert (deny-by-default); no new route guard.
- **House form-machine idiom**: `org-template.machine.ts`
(`OrgTemplateState`/`OrgTemplateMsg`, pure `reduce` + spec) — draft fields are form
state, not brief state. Store (`org-template.store.ts`, root singleton) does
debounced draft save (mirror `BriefStore.scheduleSave`), publish, rollback.
- **Publish shows impact first**: confirmation displays the WP-23 impact count
("Dit raakt N nog niet verzonden brieven") before the POST.
- **Version history is a list, rollback copies into draft** (WP-23 semantics) —
no side-by-side rendered diff (deferred; field-level history list is enough here).
- **Logo upload reuses `single-upload`** against the `org-logo` category; the canvas
shows `<img src="/api/v1/uploads/{id}/content">`.
- **Proefbrief** = the WP-25 admin preview endpoint; just a button.
- Margins are bounded number inputs (server re-validates, WP-23).
## Files
- `src/app/brief/domain/org-template.machine.ts` (+spec)
- `src/app/brief/application/org-template.store.ts`
- `src/app/brief/infrastructure/org-template.adapter.ts` (+spec, `parseOrgTemplateAdminView`)
- `src/app/brief/ui/org-template-editor/*` (organism + stories)
- `src/app/brief/ui/org-template.page.ts`
- `src/app/app.routes.ts` (route `brief/huisstijl`)
## Steps
1. Machine (fields, `FieldEdited`/`MarginEdited`/`LogoSet`/`DraftLoaded`/save-publish
outcome Msgs) + spec.
2. Adapter (generated client CRUD + parse boundary) + spec.
3. Store: load (sub-org list + selected), debounced save, publish (impact confirm),
rollback.
4. Editor UI: sub-org switcher, canvas in `'template'` mode with inline-editable
regions, margins inputs, logo upload, version history + rollback, proefbrief
button, publish bar showing live version + published-at.
5. Route + capability gate + stories (axe).
## Acceptance criteria
- [x] `?role=admin` can switch sub-orgs, edit all template fields in place on the
canvas, and see the canvas update live.
- [x] Draft saves are debounced; publish asks for confirmation showing the impact
count; after publish the drafter's canvas (WP-24) reflects it on reload.
- [x] Version history lists published versions (who is faked, when is real);
rollback copies an old version into the draft.
- [x] Non-admin on `/brief/huisstijl` sees the denial alert; API would 403 anyway.
- [x] Logo upload validates type/size client-side (existing `rejectReason`) and
renders on the canvas after upload.
- [x] Machine spec covers field edits, dirty tracking, publish/rollback outcomes.
- [x] Full GREEN.
## Deviations / notes (as built)
- **`check:tokens` was already red on `main`** (WP-24's canvas landed `var(--rhc-*,
#hex)` fallbacks + an `rgb()` paper shadow, and WP-25 committed over it). Fixed here
to end GREEN: dropped the redundant hex fallbacks (the token bridge defines every
one) and marked the paper drop-shadow `token-ok`; also fixed a pre-existing
`passage-picker` `var(--rhc-color-wit, #fff)` hit.
- **Canvas edit-in-place**: `editableRegions='template'` now renders the seven
org-identity text fields as inline `<input>`/`<textarea>` controls (aria-labelled)
and shows the logo `<img>`; the letter body stays a read-only sample
(`SAMPLE_LETTER_BRIEF`). `logoUrl` input added to the canvas and wired through the
composer too, so a published logo shows to the drafter (AC2).
- **Load-effect loop (caught in the live walk)**: the page's initial-load effect must
NOT read the store model — `load()` dispatches `Loading` (a fresh object), which
would retrigger a model-reading effect into a runaway loop that saturated the page.
Gated on `canEdit()` + a plain `loadRequested` flag instead.
- **`AccessStore.ready`** added (tiny): a page-level capability gate needs to tell
"still loading `/me`" from "denied" so an admin doesn't flash the denial alert.
- **`uploadContentUrl`** pure helper extracted from `UploadAdapter.contentUrl` so
`BriefStore` can build a logo `src` without pulling `ApiClient` into its DI graph
(kept its spec green).
- **Role caching caveat**: `/me` loads once. Open the app with `?role=admin` from the
first navigation that touches the editor (the dev role stub, like `?scenario=`);
switching role mid-session won't refetch capabilities (out of scope, POC).
- **Dirty race**: `DraftSaved` carries the saved draft and clears `dirty` only if it's
reference-equal to the current draft, so an edit landing during a save round-trip
keeps its pending save.
## Verification
GREEN one-liner; manual walk: admin edits footer + margin → canvas live-updates →
proefbrief shows draft → publish (impact count) → `?role=drafter` reload shows new
appearance; second sub-org unaffected.
## Out of scope
Template approval chain (four-eyes on templates — PRD open question, out for POC).
Rendered side-by-side version diff. Soft locks. New shared overlay/modal component —
the publish confirmation uses the existing inline confirmation pattern, not a dialog.
## Risks
The editor is the first consumer of `editableRegions='template'` — WP-24 built the
input but nothing exercised it; budget for canvas fixes here. Debounced draft save +
publish can race — flush the draft save before publishing (same
`clearTimeout`+`flushSave` discipline as `BriefStore.transition`).

View File

@@ -0,0 +1,93 @@
# WP-27 — Brief UX layer (undo/redo, standaardbrief, search, diff badges)
Status: todo
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
PRD Brief v2 §7: the working-day features that make the composer pleasant daily.
Several are nearly free **because** state is one immutable value — that's the
teaching payload: undo/redo is a shell-side snapshot list, the rejection diff is a
pure function over two values. Say so in code comments and stories.
## Read first
- PRD Brief v2 §7 (and the plan-review trim recorded below)
- `src/app/brief/application/brief.store.ts` (autosave + `SaveState` already exist)
- `src/app/brief/domain/brief.machine.ts` — the `Seed` Msg (undo/redo's restore path)
## Decisions (pre-made, don't relitigate)
- **Trim agreed at plan review.** IN: undo/redo, autosave retry affordance,
standaardbrief, passage search, canvas zoom controls, Ctrl+Z/Ctrl+Shift+Z,
block-level rejection-diff badges. OUT (deferred, one line each in Out of scope):
soft lock/takeover, case-context panel, 401 autosave grace, per-user usage counts,
shortcut-overlay dialog, inline character-level text diff.
- **Undo/redo is shell state, not machine state**: `past`/`future: Brief[]` in
`BriefStore` (cap 50; push on `edit()`; clear `future` on a new edit); restore
dispatches the **existing `Seed` Msg** — zero machine changes — then `scheduleSave()`.
- **Standaardbrief**: backend seeds `IsDefault` on 23 kern passages
(`LibraryPassageDto` gains the flag); one button, visible only while the kern
section is empty, dispatches the existing `PassagesInserted` with the default set —
one Msg, one undo step.
- **Passage search is a client-side filter** in the picker (label + content match) —
the library is small; no server search, no usage tracking.
- **Rejection diff**: pure `diffBlocks(before, after): BlockDiff[]` in
`domain/brief-diff.ts` (added/removed/changed by `blockId`); the "before" snapshot
is captured shell-side when the `Rejected` dispatch happens (POC limit: lost on
reload — comment it). Rendered as "gewijzigd sinds afwijzing" badges on the canvas;
the approver gets a "Toon wijzigingen" toggle on resubmission.
- **Autosave retry**: `SaveState.Error` already exists; add the "Opnieuw proberen"
button that calls the existing flush path. No new state.
## Files
- `src/app/brief/application/brief.store.ts` (+spec: history bounds, clear-on-edit,
redo, rejection snapshot)
- `src/app/brief/domain/brief-diff.ts` (new, +spec)
- `backend/src/BigRegister.Api/Data/BriefStore.cs` (`IsDefault` seed) +
`Contracts/Dtos.cs` (`LibraryPassageDto`) + gen:api + adapter parse
- `src/app/brief/ui/passage-picker/*` (search input)
- `src/app/brief/ui/letter-canvas/*` (diff badges, standaardbrief button, zoom controls)
- `src/app/brief/ui/brief.page.ts` (undo/redo buttons + keydown listener, retry button)
## Steps
1. `diffBlocks` + spec (added/removed/changed/unchanged; changed = same blockId,
different content).
2. Store: history + undo/redo + rejection snapshot (+spec).
3. Backend `IsDefault` + gen:api + parse.
4. UI: standaardbrief button, search, zoom, badges, keyboard, retry.
5. Stories for the new states (axe).
## Acceptance criteria
- [ ] Remove a block → Ctrl+Z restores it → Ctrl+Shift+Z re-removes; buttons mirror;
history capped at 50; a new edit clears redo; restore re-triggers autosave.
- [ ] Empty kern + "Standaardbrief invoegen" → default passages inserted as one undo
step; button gone once kern is non-empty.
- [ ] Search filters passages by label and content.
- [ ] Reject → edit → resubmit: approver toggles "Toon wijzigingen", changed/added/
removed blocks are badged (block granularity).
- [ ] Autosave failure shows "Niet opgeslagen — opnieuw proberen"; retry works;
content never lost locally.
- [ ] Full GREEN.
## Verification
GREEN one-liner; store + diff specs; manual reject→edit→diff walk with two roles.
## Out of scope (deferred, per plan review)
Soft lock/heartbeat/takeover (real session infra, no FP teaching value here).
Case-context panel (no case data exists). 401 autosave grace (auth is faked).
Per-user passage usage counts (bookkeeping, demos nothing). Shortcut overlay dialog
(no modal component exists; not worth building one). Inline character-level diff
(block granularity carries the teaching point).
## Risks
Undo history holds `Brief` snapshots — deep-frozen immutable values, so sharing is
safe, but never push non-content dispatches (status transitions, `Seed` itself) into
history or undo will replay workflow state. The rejection snapshot lives in memory
only — document it where it's captured.

View File

@@ -0,0 +1,66 @@
# WP-28 — Brief v2 demo polish (scenarios, e2e, docs)
Status: todo
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why
Phase 6 ships across five WPs; this one makes it demonstrable and closes the loop:
a demo script that maps every kept PRD §12 scenario to a URL + click path, an e2e
spec covering the new flows end-to-end, story gap-fill, and the docs/README updates
that keep CLAUDE.md and the backlog truthful.
## Read first
- PRD Brief v2 §6 (demo choreography), §12 (scenario list); WP-23..27 as built
- `e2e/` (WP-19 conventions); `src/app/shared/infrastructure/scenario.ts`
## Decisions (pre-made, don't relitigate)
- **No preset registry.** The PRD's 18 scenarios collapse onto the existing toggles:
`?role=drafter|approver|admin`, `?scenario=slow|loading|error` (the interceptor
already covers all `/api/` calls, the new endpoints included), and
`POST /brief/reset`. The demo script documents the mapping; no new interceptor
cases, no scenario code.
- Demo script lives at `docs/prd/0003-brief-v2-demo-script.md` and follows the §6
choreography (compose → preview → switch sub-org seed → "two axes, one render").
- One e2e spec, not a suite: drafter composes on canvas → submit → approve → send
pins the org-template version; admin publishes → drafter canvas reflects it.
Preview assertion is content-type-level (text/html), not pixel.
- CLAUDE.md gets the new role value + route only — keep it rules, not narrative.
## Files
- `docs/prd/0003-brief-v2-demo-script.md` (new)
- `e2e/brief-v2.spec.ts` (new)
- story gap-fill where WP-24..27 left holes
- `docs/backlog/README.md` (statuses), `CLAUDE.md` (roles/routes touch-up)
## Steps
1. Demo script: table scenario → URL + clicks, covering every kept §12 entry.
2. e2e spec (backend + FE running, WP-19 pattern).
3. Story sweep for the new components/states.
4. Docs updates.
## Acceptance criteria
- [ ] Every kept PRD §12 scenario has a working URL + click path in the script
(walked manually once).
- [ ] `npm run e2e` green, including the new spec.
- [ ] Full GREEN; backlog README statuses correct; CLAUDE.md mentions
`?role=admin` and `/brief/huisstijl`.
## Verification
Walk the demo script top to bottom against `docker compose up`; GREEN one-liner;
`npm run e2e`.
## Out of scope
New scenario interceptor cases; a scenario-switcher UI; screenshots/video.
## Risks
The demo script rots when flows change — it lists URLs + clicks only (no prose
walkthroughs), so churn stays cheap.

File diff suppressed because one or more lines are too long

197
public/letter.css Normal file
View File

@@ -0,0 +1,197 @@
/* letter.css — the FE⇄BE letter-rendering CONTRACT (WP-24/WP-25).
*
* One stylesheet, two consumers: the FE letter canvas loads it via <link>
* (index.html + Storybook preview-head), the backend HTML renderer (WP-25)
* inlines this same file. Its class-parity test is the fence against drift.
*
* Class vocabulary: .letter, .letter__letterhead, .letter__body,
* .letter__signature, .letter__footer, .letter__page-break.
* Margins arrive as --letter-margin-* custom props (mm, from the OrgTemplate);
* the defaults below match the seed template.
*
* Deliberately self-contained: no --rhc- or --bs- tokens — the backend renderer
* has no token bridge. Geometry follows the sample voorbeeldbrief-inschrijving
* (A4, return address above the envelope window, reference block, footer rule).
*/
.letter {
--letter-margin-top: 25mm;
--letter-margin-right: 25mm;
--letter-margin-bottom: 25mm;
--letter-margin-left: 25mm;
position: relative;
box-sizing: border-box;
width: 210mm; /* A4 */
max-width: 100%;
min-height: 297mm;
margin-inline: auto;
padding: var(--letter-margin-top) var(--letter-margin-right) var(--letter-margin-bottom)
var(--letter-margin-left);
background: #fff;
color: #1a1a1a;
/* Letter typography is the letter's, not the portal UI's. Licensed RO/Rijks
fonts are not shipped; system stack mirrors the app-wide decision (ADR-0003). */
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
font-size: 10.5pt;
line-height: 1.5;
display: flex;
flex-direction: column;
}
.letter p {
margin: 0 0 0.75em;
}
.letter ul,
.letter ol {
margin: 0 0 0.75em;
padding-inline-start: 1.4em;
}
/* --- Letterhead: wordmark, return address above the envelope window, reference block --- */
.letter__letterhead {
margin-block-end: 12mm;
}
.letter__letterhead .org-wordmark {
font-size: 13pt;
font-weight: 700;
margin: 0 0 10mm;
}
.letter__letterhead .return-address {
font-style: normal;
font-size: 7.5pt;
white-space: pre-line;
color: #555;
margin-block-end: 2mm;
}
/* Envelope-window position (~C5 venstercouvert): recipient block. */
.letter__letterhead .address-window {
min-height: 22mm;
font-style: normal;
white-space: pre-line;
margin-block-end: 8mm;
}
.letter__letterhead .reference {
display: flex;
gap: 10mm;
font-size: 8.5pt;
color: #333;
}
.letter__letterhead .reference dt {
font-weight: 700;
margin: 0;
}
.letter__letterhead .reference dd {
margin: 0;
}
/* --- Body: the case-type template's sections --- */
.letter__body {
flex: 1;
/* Above the absolutely-positioned page-break marks: the dashed line stays visible
in the gaps but never draws THROUGH opaque content (editors, pickers). */
position: relative;
z-index: 1;
}
.letter__body h3 {
font-size: inherit;
font-weight: 700;
margin: 0 0 0.25em;
}
.letter__body section {
margin-block-end: 1.5em;
}
/* --- Signature --- */
.letter__signature {
margin-block-start: 10mm;
}
.letter__signature p {
margin: 0;
}
.letter__signature .signature-name {
margin-block-start: 3em; /* room for the (not-shipped) handwritten signature */
font-weight: 700;
}
/* --- Footer: contact + legal, above a rule --- */
.letter__footer {
margin-block-start: 10mm;
padding-block-start: 3mm;
border-block-start: 0.5pt solid #999;
font-size: 7.5pt;
color: #555;
display: flex;
justify-content: space-between;
gap: 10mm;
}
.letter__footer .footer-contact {
white-space: pre-line;
}
.letter__footer .footer-legal {
text-align: end;
}
/* --- Approximate page-break indicator (screen only; PRD §2b honesty rule) ---
Positioned per A4-interval by the canvas; the print preview is authoritative. */
.letter__page-break {
position: absolute;
inset-inline: 0;
border-block-start: 1px dashed #b36200;
pointer-events: none;
}
.letter__page-break > span {
position: absolute;
inset-inline-end: 2mm;
inset-block-start: 0;
transform: translateY(-50%);
/* Above .letter__body: the honesty caption stays legible even over content. */
z-index: 2;
font-size: 7pt;
color: #b36200;
background: #fff;
padding-inline: 1mm;
white-space: nowrap;
}
/* --- Print: real pages, no indicator chrome --- */
@page {
size: A4;
margin: 0;
}
@media print {
.letter {
width: auto;
min-height: 0;
}
.letter__page-break {
display: none;
}
}

View File

@@ -51,6 +51,12 @@ export const routes: Routes = [
canActivate: [authGuard],
loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage),
},
{
path: 'brief/huisstijl',
canActivate: [authGuard],
loadComponent: () =>
import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
},
{
path: 'concepts',
loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage),

View File

@@ -1,8 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Result } from '@shared/kernel/fp';
import { Brief, BriefDecisions } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { BriefStore } from './brief.store';
const decisions: BriefDecisions = {
@@ -22,7 +24,20 @@ const brief: Brief = {
drafterId: 'u1',
};
const view: BriefView = { brief, availablePassages: [], decisions };
const orgTemplate: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'Postbus 00000\n2500 AA Den Haag',
footerContact: 'info@voorbeeld.example',
footerLegal: 'KvK 00000000',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1,
};
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate };
function setup(adapter: Partial<BriefAdapter>): BriefStore {
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
@@ -89,3 +104,43 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
expect(store.lastError()).toBeNull();
});
});
describe('BriefStore.previewLetter', () => {
// vi.spyOn reuses an existing spy (and its call history) if one is already on
// the property — window.open/URL.createObjectURL must be restored between tests.
afterEach(() => vi.restoreAllMocks());
it('opens the composed letter in a new tab on success', async () => {
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
});
await store.load();
const blob = new Blob(['<html></html>'], { type: 'text/html' });
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
ok: true,
value: blob,
});
await store.previewLetter();
expect(open).toHaveBeenCalledWith('blob:mock', '_blank');
expect(store.lastError()).toBeNull();
});
it('surfaces the error without opening a tab on failure', async () => {
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
});
await store.load();
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
ok: false,
error: 'De voorvertoning kon niet worden geopend.',
});
await store.previewLetter();
expect(open).not.toHaveBeenCalled();
expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.');
});
});

View File

@@ -10,7 +10,10 @@ import {
unresolvedPlaceholders,
} from '@brief/domain/brief';
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { uploadContentUrl } from '@shared/upload/upload.adapter';
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
instead of a busy boolean + a nullable error sitting side by side. */
@@ -34,6 +37,7 @@ type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
@Injectable({ providedIn: 'root' })
export class BriefStore {
private adapter = inject(BriefAdapter);
private previewAdapter = inject(LetterPreviewAdapter);
private store = createStore<BriefState, BriefMsg>(initial, reduce);
readonly model = this.store.model;
@@ -48,6 +52,17 @@ export class BriefStore {
/** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** The org template the letter renders with (WP-24). Server-owned appearance data,
not letter state — held beside the machine, never inside it (`brief.machine.ts`
stays untouched by design). Set from every server view that carries it. */
readonly orgTemplate = signal<OrgTemplate | null>(null);
/** The org logo's content URL for the letterhead, or null when the template has none. */
readonly logoUrl = computed<string | null>(() => {
const id = this.orgTemplate()?.logoDocumentId;
return id ? uploadContentUrl(id) : null;
});
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
purely a projection of its loading/failed tags onto the shared async seam. */
@@ -87,8 +102,12 @@ export class BriefStore {
async load() {
const r = await this.adapter.load();
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
if (r.ok) {
this.orgTemplate.set(r.value.orgTemplate);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
}
}
/** An edit: apply it optimistically in the pure reducer, then debounce-save. */
@@ -125,6 +144,7 @@ export class BriefStore {
this.saveState.set({ tag: 'Idle' });
if (r.ok) {
this.actionState.set({ tag: 'Idle' });
this.orgTemplate.set(r.value.orgTemplate);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
this.actionState.set({ tag: 'Failed', error: r.error });
@@ -136,6 +156,20 @@ export class BriefStore {
reject = (comments: string) => this.transition(() => this.adapter.reject(comments));
send = () => this.transition(() => this.adapter.send());
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
the tab outlives this call; not worth a teardown hook for a POC. */
async previewLetter() {
this.actionState.set({ tag: 'Busy' });
const r = await this.previewAdapter.preview();
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
window.open(URL.createObjectURL(r.value), '_blank');
}
// A transition: flush any pending save, call the server (authoritative), then mirror
// the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, BriefView>>) {
@@ -152,6 +186,8 @@ export class BriefStore {
}
private applyServerStatus(view: BriefView) {
// `send` pins the org-template version server-side — mirror whatever came back.
this.orgTemplate.set(view.orgTemplate);
const { brief, decisions } = view;
const s = brief.status;
switch (s.tag) {

View File

@@ -0,0 +1,271 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { createStore } from '@shared/application/store';
import { UploadAdapter } from '@shared/upload/upload.adapter';
import { UploadShellService } from '@shared/upload/upload-shell.service';
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
import {
MARGIN_MAX_MM,
MARGIN_MIN_MM,
OrgTemplate,
SubOrgSummary,
} from '@brief/domain/org-template';
import {
OrgTemplateMsg,
OrgTemplateState,
initial,
reduce,
} from '@brief/domain/org-template.machine';
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
/** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
const LOGO_CATEGORY = 'org-logo';
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
/**
* Root singleton for the admin org-template editor (WP-26). The Elm machine owns the
* editable draft; commands here do the debounced save, publish (impact-confirm),
* rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The
* logo upload reuses the shared upload transport; its completion mutates the draft
* (in the reducer) and triggers a save (here). Mirrors `BriefStore`.
*/
@Injectable({ providedIn: 'root' })
export class OrgTemplateStore {
private adapter = inject(OrgTemplateAdapter);
private uploadAdapter = inject(UploadAdapter);
private shell = inject(UploadShellService);
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
readonly model = this.store.model;
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
readonly selectedSubOrgId = signal<string | null>(null);
private actionState = signal<ActionState>({ tag: 'Idle' });
readonly busy = computed(() => this.actionState().tag === 'Busy');
readonly lastError = computed(() => {
const s = this.actionState();
return s.tag === 'Failed' ? s.error : null;
});
readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
readonly pendingPublish = signal(false);
readonly remoteData = computed<RemoteData<Error | undefined, LoadedState>>(() => {
const s = this.model();
switch (s.tag) {
case 'loading':
return { tag: 'Loading' };
case 'failed':
return { tag: 'Failure', error: new Error(s.reason) };
case 'loaded':
return { tag: 'Success', value: s };
}
});
private loaded = computed<LoadedState | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s : null;
});
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
readonly history = computed(() => this.loaded()?.history ?? []);
readonly publishedVersion = computed(() => this.loaded()?.publishedVersion ?? 0);
readonly unsentBriefs = computed(() => this.loaded()?.unsentBriefs ?? 0);
readonly logoUrl = computed<string | null>(() => {
const id = this.draft()?.logoDocumentId;
return id ? this.uploadAdapter.contentUrl(id) : null;
});
/** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;
the server re-validates and stays the authority — publish is gated on this. */
readonly draftValid = computed(() => {
const d = this.draft();
if (!d) return false;
const marginsOk = [d.margins.topMm, d.margins.rightMm, d.margins.bottomMm, d.margins.leftMm].every(
(v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM,
);
return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk;
});
// Live File blobs keyed by localId — needed to retry a failed upload (a reducer can't hold these).
private files = new Map<string, File>();
private categoriesRes = this.uploadAdapter.categoriesResource('org-template');
constructor() {
// Feed the logo category into the machine's upload sub-state once loaded. Tracks
// `model()` so it re-fires after a sub-org switch reseeds an empty upload state;
// the length guard makes it idempotent (no dispatch loop).
effect(() => {
const s = this.model();
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
const status = this.categoriesRes.status();
if (status === 'resolved' || status === 'local')
this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] });
});
}
async load() {
this.store.dispatch({ tag: 'Loading' });
const list = await this.adapter.list();
if (!list.ok) {
this.store.dispatch({ tag: 'LoadFailed', reason: list.error });
return;
}
this.subOrgs.set(list.value);
const first = list.value[0];
if (!first) {
this.store.dispatch({ tag: 'LoadFailed', reason: NO_SUBORGS });
return;
}
await this.selectSubOrg(first.subOrgId);
}
async selectSubOrg(subOrgId: string) {
this.selectedSubOrgId.set(subOrgId);
this.saveState.set({ tag: 'Idle' });
clearTimeout(this.saveTimer);
this.store.dispatch({ tag: 'Loading' });
const r = await this.adapter.load(subOrgId);
if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });
else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });
}
/** An in-place canvas or margin edit: apply optimistically, then debounce-save. */
edit(msg: OrgTemplateMsg) {
this.store.dispatch(msg);
this.scheduleSave();
}
private saveTimer?: ReturnType<typeof setTimeout>;
private scheduleSave() {
if (this.loaded() === null) return;
clearTimeout(this.saveTimer);
// ponytail: 600ms debounce, same as BriefStore; the server is the store of record.
this.saveTimer = setTimeout(() => void this.flushSave(), 600);
}
private async flushSave() {
const s = this.loaded();
if (!s || !s.dirty) return;
const { subOrgId, draft } = s;
this.saveState.set({ tag: 'Saving' });
const r = await this.adapter.save(subOrgId, draft);
if (r.ok) {
this.saveState.set({ tag: 'Saved' });
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
} else {
this.saveState.set({ tag: 'Error' });
this.actionState.set({ tag: 'Failed', error: r.error });
}
}
// --- publish (impact-confirm) / rollback / proefbrief ---
requestPublish() {
this.pendingPublish.set(true);
}
cancelPublish() {
this.pendingPublish.set(false);
}
async confirmPublish() {
const s = this.loaded();
if (!s) return;
this.pendingPublish.set(false);
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
await this.flushSave(); // publish the saved draft — flush any pending edit first
const r = await this.adapter.publish(s.subOrgId);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
}
async rollback(version: number) {
const s = this.loaded();
if (!s) return;
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
const r = await this.adapter.rollback(s.subOrgId, version);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
}
async proefbrief() {
const s = this.loaded();
if (!s) return;
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
await this.flushSave(); // the proefbrief renders the server's draft
const r = await this.adapter.proefbrief(s.subOrgId);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
window.open(URL.createObjectURL(r.value), '_blank');
}
// --- logo upload (reuses the shared upload transport; single `org-logo` file) ---
onLogoSelected(files: File[]) {
const s = this.loaded();
const cat = s?.upload.categories.find((c) => c.categoryId === LOGO_CATEGORY);
const file = files[0];
if (!s || !cat || !file) return;
const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 });
if (reason) {
this.dispatchUpload({ type: 'FileRejected', categoryId: cat.categoryId, reason });
return;
}
const localId = crypto.randomUUID();
this.files.set(localId, file);
this.dispatchUpload({
type: 'FileSelected',
categoryId: cat.categoryId,
localId,
fileName: file.name,
fileSizeMb: file.size / 1e6,
});
this.shell.upload({ localId, categoryId: cat.categoryId, wizardId: 'org-template', file }, (m) =>
this.onUploadMsg(m),
);
}
onLogoRemoved(localId: string) {
this.shell.cancel([localId]);
this.files.delete(localId);
this.onUploadMsg({ type: 'UploadRemoved', localId });
}
onLogoRetry(localId: string) {
const file = this.files.get(localId);
const up = this.loaded()?.upload.uploads.find((u) => u.localId === localId);
if (!file || !up) return;
this.dispatchUpload({ type: 'UploadRetried', localId });
this.shell.upload({ localId, categoryId: up.categoryId, wizardId: 'org-template', file }, (m) =>
this.onUploadMsg(m),
);
}
private dispatchUpload(msg: UploadMsg) {
this.store.dispatch({ tag: 'Upload', msg });
}
/** Upload effects arriving from the transport: a finished/removed logo edits the
draft (in the reducer) and needs persisting. */
private onUploadMsg(msg: UploadMsg) {
this.dispatchUpload(msg);
if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved') this.scheduleSave();
}
}

View File

@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
import { OrgTemplateState, reduce } from './org-template.machine';
import { DocumentCategory } from '@shared/upload/upload.machine';
const template: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG',
returnAddress: 'Postbus 1\n2500 AA Den Haag',
footerContact: 'info@cibg.nl',
footerLegal: 'CIBG is onderdeel van VWS',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
version: 3,
};
const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView => ({
draft: template,
publishedVersion: 3,
history: [],
unsentBriefs: 2,
...over,
});
const loaded = (): OrgTemplateState => reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
const logoCategory: DocumentCategory = {
categoryId: 'org-logo',
label: 'Logo',
description: '',
required: false,
acceptedTypes: ['image/png'],
maxSizeMb: 2,
multiple: false,
allowPostDelivery: false,
};
describe('org-template.machine', () => {
it('DraftLoaded moves to loaded with the draft, clean', () => {
const s = loaded();
expect(s.tag).toBe('loaded');
if (s.tag !== 'loaded') return;
expect(s.draft.orgName).toBe('CIBG');
expect(s.subOrgId).toBe('cibg-registers');
expect(s.unsentBriefs).toBe(2);
expect(s.dirty).toBe(false);
});
it('LoadFailed carries the reason', () => {
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
});
it('FieldEdited edits the draft and marks dirty', () => {
const s = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' });
expect(s.tag === 'loaded' && s.draft.orgName).toBe('CIBG Nieuw');
expect(s.tag === 'loaded' && s.dirty).toBe(true);
});
it('MarginEdited edits one edge and marks dirty', () => {
const s = reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 });
expect(s.tag === 'loaded' && s.draft.margins.topMm).toBe(40);
expect(s.tag === 'loaded' && s.draft.margins.leftMm).toBe(20);
expect(s.tag === 'loaded' && s.dirty).toBe(true);
});
it('DraftSaved clears dirty when the saved draft is the current one', () => {
const edited = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
const savedDraft = edited.tag === 'loaded' ? edited.draft : template;
const s = reduce(edited, { tag: 'DraftSaved', savedDraft });
expect(s.tag === 'loaded' && s.dirty).toBe(false);
expect(s.tag === 'loaded' && s.draft.orgName).toBe('X');
});
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
const editing = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
const savedDraft = editing.tag === 'loaded' ? editing.draft : template;
// a further edit changes the draft reference before the save resolves
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
const s = reduce(raced, { tag: 'DraftSaved', savedDraft });
expect(s.tag === 'loaded' && s.dirty).toBe(true);
});
it('edits are no-ops in non-loaded states', () => {
expect(reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' })).toEqual({
tag: 'loading',
});
});
it('a completed logo upload sets logoDocumentId + dirty', () => {
const withCat = reduce(loaded(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
});
const selected = reduce(withCat, {
tag: 'Upload',
msg: { type: 'FileSelected', categoryId: 'org-logo', localId: 'a', fileName: 'l.png', fileSizeMb: 0.1 },
});
const done = reduce(selected, {
tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
});
expect(done.tag === 'loaded' && done.draft.logoDocumentId).toBe('doc-1');
expect(done.tag === 'loaded' && done.dirty).toBe(true);
});
it('removing the logo clears logoDocumentId + dirty', () => {
const withLogo = reduce(loaded(), {
tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
});
const removed = reduce(withLogo, {
tag: 'Upload',
msg: { type: 'UploadRemoved', localId: 'a' },
});
expect(removed.tag === 'loaded' && removed.draft.logoDocumentId).toBeUndefined();
expect(removed.tag === 'loaded' && removed.dirty).toBe(true);
});
it('DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads', () => {
const withCat = reduce(loaded(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
});
const switched = reduce(withCat, {
tag: 'DraftLoaded',
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
});
expect(switched.tag === 'loaded' && switched.upload.categories).toHaveLength(1);
expect(switched.tag === 'loaded' && switched.upload.uploads).toHaveLength(0);
expect(switched.tag === 'loaded' && switched.subOrgId).toBe('cibg-vakbekwaamheid');
});
});

View File

@@ -0,0 +1,100 @@
import { assertNever } from '@shared/kernel/fp';
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine';
/**
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
* the same idiom as the wizards. The DRAFT org template is form state (edited in
* place on the canvas); publish/rollback are effects that come back as `DraftLoaded`.
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
* the composable upload sub-machine folded in, exactly like the wizards fold
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
*/
/** The org-identity text fields editable directly on the letter canvas. */
export type OrgTemplateTextField =
| 'orgName'
| 'returnAddress'
| 'footerContact'
| 'footerLegal'
| 'signatureName'
| 'signatureRole'
| 'signatureClosing';
export type OrgTemplateState =
| { tag: 'loading' }
| { tag: 'failed'; reason: string }
| {
tag: 'loaded';
subOrgId: string;
draft: OrgTemplate;
publishedVersion: number;
history: readonly OrgTemplateVersion[];
unsentBriefs: number;
dirty: boolean;
/** Logo upload sub-state (single file, `org-logo` category). */
upload: UploadState;
};
export const initial: OrgTemplateState = { tag: 'loading' };
export type OrgTemplateMsg =
| { tag: 'Loading' }
| { tag: 'DraftLoaded'; view: OrgTemplateAdminView }
| { tag: 'LoadFailed'; reason: string }
| { tag: 'FieldEdited'; field: OrgTemplateTextField; value: string }
| { tag: 'MarginEdited'; edge: keyof Margins; value: number }
/** Carries the draft that was saved: clears `dirty` only if no edit landed during
the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
| { tag: 'DraftSaved'; savedDraft: OrgTemplate }
| { tag: 'Upload'; msg: UploadMsg };
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
function editDraft(
s: OrgTemplateState,
f: (draft: OrgTemplate) => OrgTemplate,
): OrgTemplateState {
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
}
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
switch (m.tag) {
case 'Loading':
return { tag: 'loading' };
case 'LoadFailed':
return { tag: 'failed', reason: m.reason };
case 'DraftLoaded':
return {
tag: 'loaded',
subOrgId: m.view.draft.subOrgId,
draft: m.view.draft,
publishedVersion: m.view.publishedVersion,
history: m.view.history,
unsentBriefs: m.view.unsentBriefs,
dirty: false,
// Keep the loaded logo category across sub-org switches (it's the same
// `org-logo` category, loaded once); drop only any in-flight/finished uploads.
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
};
case 'FieldEdited':
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
case 'MarginEdited':
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
case 'DraftSaved':
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
case 'Upload': {
if (s.tag !== 'loaded') return s;
const upload = reduceUpload(s.upload, m.msg);
// A completed/removed logo upload also updates the draft's logoDocumentId.
if (m.msg.type === 'UploadComplete')
return { ...s, upload, draft: { ...s.draft, logoDocumentId: m.msg.documentId }, dirty: true };
if (m.msg.type === 'UploadRemoved') {
const { logoDocumentId: _dropped, ...rest } = s.draft;
return { ...s, upload, draft: rest, dirty: true };
}
return { ...s, upload };
}
default:
return assertNever(m);
}
}

View File

@@ -0,0 +1,67 @@
/**
* The organization template (Brief v2 PRD §3, WP-23/24): 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, on the letter canvas. Server-owned: the FE renders it verbatim,
* never edits it here (the admin editor is WP-26).
*/
export interface Margins {
readonly topMm: number;
readonly rightMm: number;
readonly bottomMm: number;
readonly leftMm: number;
}
export interface OrgTemplate {
readonly subOrgId: string;
readonly orgName: string;
/** Multiline; rendered above the envelope window. */
readonly returnAddress: string;
readonly logoDocumentId?: string;
/** Multiline contact block in the footer. */
readonly footerContact: string;
readonly footerLegal: string;
readonly signatureName: string;
readonly signatureRole: string;
readonly signatureClosing: string;
readonly margins: Margins;
/** 0 = draft; n>0 = the published snapshot this letter renders with. */
readonly version: number;
}
// --- admin editor (WP-26) ---
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */
export interface OrgTemplateVersion {
readonly version: number;
readonly publishedAt: string;
readonly template: OrgTemplate;
}
/** The admin editor's view of one sub-org: the editable draft plus publish metadata. */
export interface OrgTemplateAdminView {
readonly draft: OrgTemplate;
readonly publishedVersion: number;
readonly history: readonly OrgTemplateVersion[];
/** How many not-yet-sent letters a publish would re-render (the impact count). */
readonly unsentBriefs: number;
}
/** One row in the sub-org switcher. */
export interface SubOrgSummary {
readonly subOrgId: string;
readonly orgName: string;
readonly publishedVersion: number;
}
/** Publish outcome: the new version and how many unsent letters it touched. */
export interface PublishResult {
readonly version: number;
readonly affectedUnsentBriefs: number;
}
/** Margin bounds (server-owned, `OrgTemplateRules`): the FE mirrors them for instant
feedback via `<input min max>`; the server re-validates and stays the authority. */
export const MARGIN_MIN_MM = 10;
export const MARGIN_MAX_MM = 50;

View File

@@ -1,6 +1,12 @@
import { describe, it, expect } from 'vitest';
import { BriefViewDto } from '@shared/infrastructure/api-client';
import { parseBrief, parseBriefView, parseNode, parseStatus } from './brief.adapter';
import {
parseBrief,
parseBriefView,
parseNode,
parseOrgTemplate,
parseStatus,
} from './brief.adapter';
const view: BriefViewDto = {
brief: {
@@ -47,6 +53,18 @@ const view: BriefViewDto = {
},
],
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false },
orgTemplate: {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
footerLegal: 'KvK 00000000',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1,
},
};
describe('brief.adapter parse boundary', () => {
@@ -76,6 +94,26 @@ describe('brief.adapter parse boundary', () => {
});
});
it('parses the org template and drops a null logoDocumentId', () => {
const r = parseOrgTemplate(view.orgTemplate);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.orgName).toBe('CIBG — Registers');
expect(r.value.margins).toEqual({ topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 });
expect('logoDocumentId' in r.value).toBe(false);
});
it('rejects a view whose org template is missing or malformed', () => {
expect(parseBriefView({ ...view, orgTemplate: undefined }).ok).toBe(false);
expect(parseOrgTemplate({ ...view.orgTemplate, signatureName: undefined }).ok).toBe(false);
expect(
parseOrgTemplate({
...view.orgTemplate,
margins: { topMm: 25, rightMm: 25, bottomMm: 25 },
}).ok,
).toBe(false);
});
it('rejects a view whose decisions are missing or malformed', () => {
expect(parseBriefView({ ...view, decisions: undefined as never }).ok).toBe(false);
expect(

View File

@@ -10,6 +10,7 @@ import {
LetterBlockDto,
LetterSectionDto,
LibraryPassageDto,
OrgTemplateDto,
PlaceholderDefDto,
RichTextBlockDto,
RichTextNodeDto,
@@ -22,6 +23,7 @@ import {
LetterSection,
LibraryPassage,
} from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { PlaceholderDef } from '@brief/domain/placeholders';
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
@@ -37,6 +39,7 @@ export interface BriefView {
readonly brief: Brief;
readonly availablePassages: LibraryPassage[];
readonly decisions: BriefDecisions;
readonly orgTemplate: OrgTemplate;
}
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
@@ -301,19 +304,64 @@ function parseDecisions(dto: BriefDecisionsDto | undefined): Result<string, Brie
});
}
export function parseOrgTemplate(dto: OrgTemplateDto | undefined): Result<string, OrgTemplate> {
if (
typeof dto?.subOrgId !== 'string' ||
typeof dto.orgName !== 'string' ||
typeof dto.returnAddress !== 'string' ||
typeof dto.footerContact !== 'string' ||
typeof dto.footerLegal !== 'string' ||
typeof dto.signatureName !== 'string' ||
typeof dto.signatureRole !== 'string' ||
typeof dto.signatureClosing !== 'string' ||
typeof dto.version !== 'number'
) {
return err('org-template: bad shape');
}
const m = dto.margins;
if (
typeof m?.topMm !== 'number' ||
typeof m.rightMm !== 'number' ||
typeof m.bottomMm !== 'number' ||
typeof m.leftMm !== 'number'
) {
return err('org-template: bad margins');
}
return ok({
subOrgId: dto.subOrgId,
orgName: dto.orgName,
returnAddress: dto.returnAddress,
...(dto.logoDocumentId != null ? { logoDocumentId: dto.logoDocumentId } : {}),
footerContact: dto.footerContact,
footerLegal: dto.footerLegal,
signatureName: dto.signatureName,
signatureRole: dto.signatureRole,
signatureClosing: dto.signatureClosing,
margins: { topMm: m.topMm, rightMm: m.rightMm, bottomMm: m.bottomMm, leftMm: m.leftMm },
version: dto.version,
});
}
export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
if (!dto.brief) return err('brief-view: missing brief');
const brief = parseBrief(dto.brief);
if (!brief.ok) return brief;
const decisions = parseDecisions(dto.decisions);
if (!decisions.ok) return decisions;
const orgTemplate = parseOrgTemplate(dto.orgTemplate);
if (!orgTemplate.ok) return orgTemplate;
const availablePassages: LibraryPassage[] = [];
for (const p of dto.availablePassages ?? []) {
const parsed = parsePassage(p);
if (!parsed.ok) return parsed;
availablePassages.push(parsed.value);
}
return ok({ brief: brief.value, availablePassages, decisions: decisions.value });
return ok({
brief: brief.value,
availablePassages,
decisions: decisions.value,
orgTemplate: orgTemplate.value,
});
}
// --- toDto: domain → wire, for save (collapses the union to the flat shape) ---

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '../../../environments/environment';
const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`;
/**
* `/brief/preview` returns `text/html`, not JSON, and is `.ExcludeFromDescription()`'d
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
* `roleInterceptor`, so `X-Role` is set here explicitly.
*/
@Injectable({ providedIn: 'root' })
export class LetterPreviewAdapter {
async preview(): Promise<Result<string, Blob>> {
let res: Response;
try {
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
headers: { 'X-Role': currentRole() },
});
} catch {
return err(PREVIEW_FAILED);
}
if (!res.ok) return err(await errorMessage(res));
return ok(await res.blob());
}
}
async function errorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), PREVIEW_FAILED);
} catch {
return PREVIEW_FAILED;
}
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
import { parseOrgTemplateAdminView } from './org-template.adapter';
const draft: OrgTemplateDto = {
subOrgId: 'cibg-registers',
orgName: 'CIBG',
returnAddress: 'Postbus 1',
footerContact: 'info@cibg.nl',
footerLegal: 'onderdeel van VWS',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
version: 3,
};
const view: OrgTemplateAdminViewDto = {
draft,
publishedVersion: 3,
unsentBriefs: 2,
history: [{ version: 2, publishedAt: '2026-06-01', template: draft }],
};
describe('parseOrgTemplateAdminView', () => {
it('parses a well-formed admin view', () => {
const r = parseOrgTemplateAdminView(view);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.draft.orgName).toBe('CIBG');
expect(r.value.publishedVersion).toBe(3);
expect(r.value.unsentBriefs).toBe(2);
expect(r.value.history).toHaveLength(1);
expect(r.value.history[0].version).toBe(2);
});
it('rejects a missing draft', () => {
const r = parseOrgTemplateAdminView({ ...view, draft: undefined });
expect(r.ok).toBe(false);
});
it('rejects a missing count field', () => {
const r = parseOrgTemplateAdminView({ ...view, unsentBriefs: undefined });
expect(r.ok).toBe(false);
});
it('rejects a malformed history entry', () => {
const r = parseOrgTemplateAdminView({
...view,
history: [{ version: 2, publishedAt: '2026-06-01', template: { ...draft, orgName: undefined } }],
});
expect(r.ok).toBe(false);
});
});

View File

@@ -0,0 +1,163 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '../../../environments/environment';
import {
ApiClient,
OrgTemplateAdminViewDto,
OrgTemplateDto,
OrgTemplateVersionDto,
PublishOrgTemplateResponse,
SubOrgSummaryDto,
} from '@shared/infrastructure/api-client';
import {
OrgTemplate,
OrgTemplateAdminView,
OrgTemplateVersion,
PublishResult,
SubOrgSummary,
} from '@brief/domain/org-template';
import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
/**
* The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/
* rollback go through the generated client (X-Role added by `roleInterceptor`);
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
*/
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
@Injectable({ providedIn: 'root' })
export class OrgTemplateAdapter {
private client = inject(ApiClient);
async list(): Promise<Result<string, SubOrgSummary[]>> {
const r = await runSubmit(() => this.client.orgTemplates(), FAILED);
if (!r.ok) return r;
const out: SubOrgSummary[] = [];
for (const s of r.value ?? []) {
const parsed = parseSubOrg(s);
if (!parsed.ok) return parsed;
out.push(parsed.value);
}
return ok(out);
}
async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);
return r.ok ? parseAdminView(r.value) : r;
}
async save(subOrgId: string, draft: OrgTemplate): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(
() => this.client.orgTemplatePUT(subOrgId, { draft: toDto(draft) }),
FAILED,
);
return r.ok ? parseAdminView(r.value) : r;
}
async publish(subOrgId: string): Promise<Result<string, PublishResult>> {
const r = await runSubmit(() => this.client.orgTemplatePublish(subOrgId), FAILED);
return r.ok ? parsePublish(r.value) : r;
}
async rollback(
subOrgId: string,
version: number,
): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED);
return r.ok ? parseAdminView(r.value) : r;
}
/** Proefbrief: the unpublished draft rendered over a fixture letter, opened as a Blob. */
async proefbrief(subOrgId: string): Promise<Result<string, Blob>> {
let res: Response;
try {
res = await fetch(
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
{ headers: { 'X-Role': currentRole() } },
);
} catch {
return err(PROEFBRIEF_FAILED);
}
if (!res.ok) {
try {
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
} catch {
return err(PROEFBRIEF_FAILED);
}
}
return ok(await res.blob());
}
}
// --- parse: wire → domain, validating at the boundary ---
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
if (typeof dto.subOrgId !== 'string' || typeof dto.orgName !== 'string')
return err('sub-org: bad shape');
return ok({
subOrgId: dto.subOrgId,
orgName: dto.orgName,
publishedVersion: dto.publishedVersion ?? 0,
});
}
function parseVersion(dto: OrgTemplateVersionDto): Result<string, OrgTemplateVersion> {
if (typeof dto.version !== 'number' || typeof dto.publishedAt !== 'string')
return err('version: bad shape');
const template = parseOrgTemplate(dto.template);
if (!template.ok) return template;
return ok({ version: dto.version, publishedAt: dto.publishedAt, template: template.value });
}
export function parseOrgTemplateAdminView(
dto: OrgTemplateAdminViewDto,
): Result<string, OrgTemplateAdminView> {
const draft = parseOrgTemplate(dto.draft);
if (!draft.ok) return draft;
if (typeof dto.publishedVersion !== 'number' || typeof dto.unsentBriefs !== 'number')
return err('admin-view: bad shape');
const history: OrgTemplateVersion[] = [];
for (const v of dto.history ?? []) {
const parsed = parseVersion(v);
if (!parsed.ok) return parsed;
history.push(parsed.value);
}
return ok({
draft: draft.value,
publishedVersion: dto.publishedVersion,
history,
unsentBriefs: dto.unsentBriefs,
});
}
const parseAdminView = parseOrgTemplateAdminView;
function parsePublish(dto: PublishOrgTemplateResponse): Result<string, PublishResult> {
if (typeof dto.version !== 'number' || typeof dto.affectedUnsentBriefs !== 'number')
return err('publish: bad shape');
return ok({ version: dto.version, affectedUnsentBriefs: dto.affectedUnsentBriefs });
}
// --- toDto: domain → wire (for save) ---
function toDto(t: OrgTemplate): OrgTemplateDto {
return {
subOrgId: t.subOrgId,
orgName: t.orgName,
returnAddress: t.returnAddress,
...(t.logoDocumentId != null ? { logoDocumentId: t.logoDocumentId } : {}),
footerContact: t.footerContact,
footerLegal: t.footerLegal,
signatureName: t.signatureName,
signatureRole: t.signatureRole,
signatureClosing: t.signatureClosing,
margins: { ...t.margins },
version: t.version,
};
}

View File

@@ -40,28 +40,33 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
</ng-template>
<ng-template appAsyncLoaded>
@if (loaded(); as s) {
<div class="brief-toolbar">
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
resetLabel
}}</app-button>
</div>
<app-letter-composer
[brief]="s.brief"
[availablePassages]="s.availablePassages"
[diagnostics]="store.diagnostics()"
[canEdit]="store.canEdit()"
[canApprove]="store.canApprove()"
[canReject]="store.canReject()"
[canSend]="store.canSend()"
[canSubmit]="store.canSubmit()"
[busy]="store.busy()"
(edit)="store.edit($event)"
(submit)="store.submit()"
(approve)="store.approve()"
(reject)="store.reject($event)"
(send)="store.send()"
/>
@if (store.orgTemplate(); as orgTemplate) {
<div class="brief-toolbar">
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
resetLabel
}}</app-button>
</div>
<app-letter-composer
[brief]="s.brief"
[orgTemplate]="orgTemplate"
[logoUrl]="store.logoUrl()"
[availablePassages]="s.availablePassages"
[diagnostics]="store.diagnostics()"
[canEdit]="store.canEdit()"
[canApprove]="store.canApprove()"
[canReject]="store.canReject()"
[canSend]="store.canSend()"
[canSubmit]="store.canSubmit()"
[busy]="store.busy()"
(edit)="store.edit($event)"
(submit)="store.submit()"
(approve)="store.approve()"
(reject)="store.reject($event)"
(send)="store.send()"
(preview)="store.previewLetter()"
/>
}
}
</ng-template>
</app-async>

View File

@@ -0,0 +1,413 @@
import {
Component,
DestroyRef,
ElementRef,
computed,
effect,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock, LibraryPassage } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
type PreviewSegment = {
readonly list: 'bullet' | 'number' | null;
readonly items: readonly Paragraph[];
};
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
for (const para of paras) {
const kind = para.list ?? null;
const last = out[out.length - 1];
if (kind && last && last.list === kind) last.items.push(para);
else out.push({ list: kind, items: [para] });
}
return out;
}
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
const SAMPLE_VALUES: Record<string, string> = {
naam_zorgverlener: 'J. Jansen',
big_nummer: '12345678901',
};
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
const A4_HEIGHT_PX = (297 * 96) / 25.4;
/** Organism: the letter as one surface — the org template's letterhead, signature and
footer around the case-type template's sections. `editableRegions` picks who edits
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
renders everything read-only (approver/locked, absorbs the old letter-preview),
`'template'` reserves the org-identity regions for the admin editor (WP-26).
Letter typography/geometry come from the shared `public/letter.css` contract —
the same file the backend preview renderer inlines (WP-25). */
@Component({
selector: 'app-letter-canvas',
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent, LetterSectionComponent],
styles: [
`
:host {
display: block;
}
.toolbar {
display: flex;
justify-content: flex-end;
margin-block-end: var(--rhc-space-max-sm);
}
/* Portal-side chrome around the letter surface (not part of the contract file). */
.surface {
background: var(--rhc-color-grijs-100);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-lg);
overflow-x: auto;
}
.surface .letter {
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */
}
/* Org-identity regions: visibly not the drafter's to edit. */
.from-template {
background: var(--rhc-color-grijs-100);
outline: 2mm solid var(--rhc-color-grijs-100);
}
.from-template-caption {
font-size: 7.5pt;
/* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */
color: var(--rhc-color-grijs-700);
margin: 0 0 2mm;
}
/* Admin edit-in-place (editableRegions='template'): the org-identity fields
become controls styled to sit in the letter, with a visible editable affordance. */
.tmpl-input,
.tmpl-textarea {
font: inherit;
color: inherit;
width: 100%;
box-sizing: border-box;
background: var(--rhc-color-geel-100);
border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);
border-radius: var(--rhc-border-radius-sm);
padding: 0.5mm 1mm;
}
.tmpl-textarea {
resize: vertical;
}
.org-logo {
max-height: 20mm;
max-width: 60mm;
margin-block-end: 3mm;
}
`,
],
template: `
<ng-template #line let-nodes>
@for (node of nodes; track $index) {
@switch (node.type) {
@case ('text') {
<span>{{ node.text }}</span>
}
@case ('lineBreak') {
<br />
}
@case ('placeholder') {
@if (showSample() && autoFor(node.key)) {
<span>{{ sampleFor(node.key) }}</span>
} @else {
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
}
}
}
}
</ng-template>
@if (editableRegions() === 'none') {
<div class="toolbar">
<app-button
variant="subtle"
(click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()"
>
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
</app-button>
</div>
}
<div class="surface">
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoom()">
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
(robijn footer background) — the letter surface must stay letter.css-only. -->
<div class="letter__letterhead" [class.from-template]="tintTemplate()">
@if (tintTemplate()) {
<p class="from-template-caption">{{ fromTemplateCaption() }}</p>
}
@if (logoUrl()) {
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
}
@if (editing()) {
<input
class="tmpl-input org-wordmark"
[value]="orgTemplate().orgName"
[attr.aria-label]="orgNameLabel()"
(input)="emitEdit('orgName', $event)"
/>
<textarea
class="tmpl-textarea return-address"
rows="2"
[value]="orgTemplate().returnAddress"
[attr.aria-label]="returnAddressLabel()"
(input)="emitEdit('returnAddress', $event)"
></textarea>
} @else {
<p class="org-wordmark">{{ orgTemplate().orgName }}</p>
<address class="return-address">{{ orgTemplate().returnAddress }}</address>
}
<address class="address-window">{{ recipientText() }}</address>
<dl class="reference">
<div>
<dt>{{ referenceLabel() }}</dt>
<dd>{{ brief().briefId }}</dd>
</div>
<div>
<dt>{{ dateLabel() }}</dt>
<dd>{{ letterDate }}</dd>
</div>
</dl>
</div>
<div class="letter__body">
@if (editableRegions() === 'content') {
@for (section of brief().sections; track section.sectionKey) {
<section>
<app-letter-section
[section]="section"
[availablePassages]="availablePassages()"
[placeholders]="placeholders()"
[editable]="!section.locked"
(edit)="edit.emit($event)"
/>
</section>
}
} @else {
@for (section of brief().sections; track section.sectionKey) {
<section>
<h3>{{ section.title }}</h3>
@for (block of section.blocks; track block.blockId) {
@for (seg of segmentsOf(block); track $index) {
@if (seg.list === 'bullet') {
<ul>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
/>
</li>
}
</ul>
} @else if (seg.list === 'number') {
<ol>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
/>
</li>
}
</ol>
} @else {
<p>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
/>
</p>
}
}
}
</section>
}
}
</div>
<div class="letter__signature" [class.from-template]="tintTemplate()">
@if (editing()) {
<input
class="tmpl-input"
[value]="orgTemplate().signatureClosing"
[attr.aria-label]="signatureClosingLabel()"
(input)="emitEdit('signatureClosing', $event)"
/>
<input
class="tmpl-input signature-name"
[value]="orgTemplate().signatureName"
[attr.aria-label]="signatureNameLabel()"
(input)="emitEdit('signatureName', $event)"
/>
<input
class="tmpl-input"
[value]="orgTemplate().signatureRole"
[attr.aria-label]="signatureRoleLabel()"
(input)="emitEdit('signatureRole', $event)"
/>
} @else {
<p>{{ orgTemplate().signatureClosing }}</p>
<p class="signature-name">{{ orgTemplate().signatureName }}</p>
<p>{{ orgTemplate().signatureRole }}</p>
}
</div>
<div class="letter__footer" [class.from-template]="tintTemplate()">
@if (editing()) {
<textarea
class="tmpl-textarea footer-contact"
rows="2"
[value]="orgTemplate().footerContact"
[attr.aria-label]="footerContactLabel()"
(input)="emitEdit('footerContact', $event)"
></textarea>
<input
class="tmpl-input footer-legal"
[value]="orgTemplate().footerLegal"
[attr.aria-label]="footerLegalLabel()"
(input)="emitEdit('footerLegal', $event)"
/>
} @else {
<div class="footer-contact">{{ orgTemplate().footerContact }}</div>
<div class="footer-legal">{{ orgTemplate().footerLegal }}</div>
}
</div>
@for (top of pageBreaks(); track $index) {
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
<span>{{ pageBreakCaption() }}</span>
</div>
}
</div>
</div>
`,
})
export class LetterCanvasComponent {
brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>();
/** Who edits what on the surface: drafter ('content'), read-only ('none'),
admin editor ('template', consumer arrives in WP-26). */
editableRegions = input<'content' | 'template' | 'none'>('none');
availablePassages = input<readonly LibraryPassage[]>([]);
placeholders = input<readonly PlaceholderOption[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
zoom = input(1);
/** The org logo's content URL (letterhead), or null when none is set. */
logoUrl = input<string | null>(null);
edit = output<BriefMsg>();
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
fromTemplateCaption = input(
$localize`:@@brief.canvas.fromTemplate:Komt uit de huisstijl van de organisatie — niet bewerkbaar in de brief.`,
);
pageBreakCaption = input(
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
);
recipientText = input(
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
);
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);
orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);
returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);
signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);
signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
protected showSample = signal(false);
protected letterDate = formatDatumNl(new Date());
/** The letterhead/signature/footer are tinted "not yours" only while composing —
in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */
protected tintTemplate = computed(() => this.editableRegions() === 'content');
/** Admin edit-in-place: the org-identity regions render as controls. */
protected editing = computed(() => this.editableRegions() === 'template');
protected emitEdit(field: OrgTemplateTextField, event: Event) {
this.templateEdit.emit({ field, value: (event.target as HTMLInputElement | HTMLTextAreaElement).value });
}
protected marginStyle = computed(() => {
const m = this.orgTemplate().margins;
return {
'--letter-margin-top': `${m.topMm}mm`,
'--letter-margin-right': `${m.rightMm}mm`,
'--letter-margin-bottom': `${m.bottomMm}mm`,
'--letter-margin-left': `${m.leftMm}mm`,
};
});
// --- read-only rendering helpers (migrated from the superseded letter-preview) ---
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
private worst = computed(() => {
const m = new Map<string, 'error' | 'warning'>();
for (const d of this.diagnostics()) {
if (!d.placeholderKey) continue;
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
}
return m;
});
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
protected sampleFor = (key: string) =>
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
private page = viewChild<ElementRef<HTMLElement>>('page');
protected pageBreaks = signal<readonly number[]>([]);
constructor() {
// ponytail: whole-surface height / A4-interval — ignores that a break never truly
// falls mid-line; the caption says "±" and WP-25's server preview is authoritative.
const observer = new ResizeObserver(([entry]) => {
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
this.pageBreaks.set(
Array.from({ length: Math.max(0, pages - 1) }, (_, i) => (i + 1) * A4_HEIGHT_PX),
);
});
effect((onCleanup) => {
const el = this.page()?.nativeElement;
if (!el) return;
observer.observe(el);
onCleanup(() => observer.unobserve(el));
});
inject(DestroyRef).onDestroy(() => observer.disconnect());
}
}

View File

@@ -0,0 +1,154 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { Brief, LibraryPassage, allDiagnostics } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { LetterCanvasComponent } from './letter-canvas.component';
const orgTemplate: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example\n070 000 00 00',
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1,
};
const passages: LibraryPassage[] = [
{
passageId: 'p1',
scope: 'global',
sectionKey: 'kern',
label: 'Standaard toelichting',
version: 1,
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Standaardtekst.' }] }] },
},
];
const brief: Brief = {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
drafterId: 'demo-drafter',
status: { tag: 'draft' },
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
],
sections: [
{
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
locked: true,
blocks: [
{
type: 'passage',
blockId: 'local-1',
sourcePassageId: 'p1',
sourceVersion: 1,
edited: false,
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte heer/mevrouw ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
],
},
},
],
},
{
sectionKey: 'kern',
title: 'Kern van het besluit',
required: true,
locked: false,
blocks: [
{
type: 'freeText',
blockId: 'local-2',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Wij hebben besloten om reden ' },
{ type: 'placeholder', key: 'reden_besluit' },
{ type: 'text', text: '.' },
],
},
],
},
},
],
},
],
};
/** Enough repeated body text to push the surface past one A4 page. */
const longBrief: Brief = {
...brief,
sections: brief.sections.map((s) =>
s.sectionKey === 'kern'
? {
...s,
blocks: [
{
type: 'freeText',
blockId: 'local-long',
content: {
paragraphs: Array.from({ length: 40 }, (_, i) => ({
nodes: [
{
type: 'text' as const,
text: `Alinea ${i + 1}: de beoordeling van uw aanvraag is uitgevoerd volgens de geldende regels voor herregistratie in het BIG-register.`,
},
],
})),
},
},
],
}
: s,
),
};
const meta: Meta<LetterCanvasComponent> = {
title: 'Domein/Brief/Letter Canvas',
component: LetterCanvasComponent,
args: {
brief,
orgTemplate,
availablePassages: passages,
placeholders: brief.placeholders,
diagnostics: allDiagnostics(brief),
},
};
export default meta;
type Story = StoryObj<LetterCanvasComponent>;
/** Drafter: content blocks editable in place; org-identity regions tinted read-only. */
export const ContentMode: Story = { args: { editableRegions: 'content' } };
/** Approver/locked: the identical surface fully read-only, with the sample-values
toggle and diagnostic placeholder chips (absorbs the old Letter Preview). */
export const ReadOnly: Story = { args: { editableRegions: 'none' } };
export const ReadOnlyZonderBevindingen: Story = {
args: { editableRegions: 'none', diagnostics: [] },
};
/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */
export const TemplateMode: Story = { args: { editableRegions: 'template' } };
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
/** Long letter: the approximate ±page-break marks appear per A4 interval. */
export const PageBreak: Story = {
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
};

View File

@@ -5,18 +5,18 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { Brief, LibraryPassage } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
import { LetterPreviewComponent } from '@brief/ui/letter-preview/letter-preview.component';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
/** Organism: the whole letter — status badge, the editable sections (drafter) or a
read-only preview (approver / locked), the diagnostics panel, and the action bar
appropriate to status × permission. Presentational: emits edit + transition
intents; the canEdit/canApprove/canReject/canSend inputs are server-computed
decision flags (PRD-0002 phase P1) — this component never derives them. */
/** Organism: the whole letter flow — status badge, the letter canvas (editable in
place for the drafter, read-only for approver/locked), the diagnostics panel, and
the action bar appropriate to status × permission. Presentational: emits edit +
transition intents; the canEdit/canApprove/canReject/canSend inputs are
server-computed decision flags (PRD-0002 phase P1) — this component never derives them. */
@Component({
selector: 'app-letter-composer',
imports: [
@@ -24,8 +24,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
StatusBadgeComponent,
ButtonComponent,
AlertComponent,
LetterSectionComponent,
LetterPreviewComponent,
LetterCanvasComponent,
DiagnosticsPanelComponent,
RejectionCommentsComponent,
],
@@ -42,9 +41,10 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
flex-wrap: wrap;
margin-block-end: var(--rhc-space-max-lg);
}
.sections {
display: grid;
gap: var(--rhc-space-max-2xl);
.head-end {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
}
.panel {
margin-block: var(--rhc-space-max-xl);
@@ -61,28 +61,28 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
template: `
<div class="head">
<app-heading [level]="2">{{ title() }}</app-heading>
<app-status-badge [label]="statusLabel()" [color]="statusColor()" />
<div class="head-end">
<app-button variant="subtle" [disabled]="busy()" (click)="preview.emit()">{{
previewLabel()
}}</app-button>
<app-status-badge [label]="statusLabel()" [color]="statusColor()" />
</div>
</div>
@if (status() === 'rejected') {
<app-rejection-comments mode="show" [comments]="rejectComments()" />
}
@if (canEdit()) {
<div class="sections">
@for (section of brief().sections; track section.sectionKey) {
<app-letter-section
[section]="section"
[availablePassages]="availablePassages()"
[placeholders]="menu()"
[editable]="!section.locked"
(edit)="edit.emit($event)"
/>
}
</div>
} @else {
<app-letter-preview [brief]="brief()" [diagnostics]="diagnostics()" />
}
<app-letter-canvas
[brief]="brief()"
[orgTemplate]="orgTemplate()"
[logoUrl]="logoUrl()"
[editableRegions]="canEdit() ? 'content' : 'none'"
[availablePassages]="availablePassages()"
[placeholders]="menu()"
[diagnostics]="diagnostics()"
(edit)="edit.emit($event)"
/>
<div class="panel">
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
@@ -139,6 +139,8 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
})
export class LetterComposerComponent {
brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>();
logoUrl = input<string | null>(null);
availablePassages = input<readonly LibraryPassage[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
canEdit = input(false);
@@ -153,9 +155,11 @@ export class LetterComposerComponent {
approve = output<void>();
reject = output<string>();
send = output<void>();
preview = output<void>();
locate = output<Diagnostic>();
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
submitHint = input(

View File

@@ -2,6 +2,20 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { LetterComposerComponent } from './letter-composer.component';
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from '@brief/domain/brief';
import { allDiagnostics } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
const orgTemplate: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1,
};
const passages: LibraryPassage[] = [
{
@@ -106,15 +120,16 @@ function brief(status: BriefStatus): Brief {
const render = (b: Brief, decisions: BriefDecisions) => ({
props: {
brief: b,
orgTemplate,
availablePassages: passages,
diagnostics: allDiagnostics(b),
...decisions,
canSubmit: true,
busy: false,
},
template: `<app-letter-composer [brief]="brief" [availablePassages]="availablePassages" [diagnostics]="diagnostics"
[canEdit]="canEdit" [canApprove]="canApprove" [canReject]="canReject" [canSend]="canSend"
[canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate" [availablePassages]="availablePassages"
[diagnostics]="diagnostics" [canEdit]="canEdit" [canApprove]="canApprove" [canReject]="canReject"
[canSend]="canSend" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
});
const meta: Meta<LetterComposerComponent> = {

View File

@@ -1,175 +0,0 @@
import { Component, computed, input, signal } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock } from '@brief/domain/brief';
import { Diagnostic } from '@brief/domain/placeholders';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
type PreviewSegment = {
readonly list: 'bullet' | 'number' | null;
readonly items: readonly Paragraph[];
};
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
for (const para of paras) {
const kind = para.list ?? null;
const last = out[out.length - 1];
if (kind && last && last.list === kind) last.items.push(para);
else out.push({ list: kind, items: [para] });
}
return out;
}
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
const SAMPLE_VALUES: Record<string, string> = {
naam_zorgverlener: 'J. Jansen',
big_nummer: '12345678901',
};
/** Organism: read-only rendering of the letter as the approver/recipient sees it.
Placeholders show as labelled chips (values are resolved server-side only at send);
a chip flagged by the linter shows its error/warning state. The "Voorbeeld" toggle
fills auto-resolvable placeholders with sample values to preview the sent result. */
@Component({
selector: 'app-letter-preview',
imports: [NgTemplateOutlet, HeadingComponent, ButtonComponent, PlaceholderChipComponent],
styles: [
`
:host {
display: block;
}
.toolbar {
display: flex;
justify-content: flex-end;
margin-block-end: var(--rhc-space-max-sm);
}
.letter {
background: var(--rhc-color-wit);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-2xl);
}
section {
margin-block-end: var(--rhc-space-max-xl);
}
p {
margin: 0 0 var(--rhc-space-max-sm);
}
ul,
ol {
margin: 0 0 var(--rhc-space-max-sm);
padding-inline-start: 1.4em;
}
`,
],
template: `
<ng-template #line let-nodes>
@for (node of nodes; track $index) {
@switch (node.type) {
@case ('text') {
<span>{{ node.text }}</span>
}
@case ('lineBreak') {
<br />
}
@case ('placeholder') {
@if (showSample() && autoFor(node.key)) {
<span>{{ sampleFor(node.key) }}</span>
} @else {
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
}
}
}
}
</ng-template>
<div class="toolbar">
<app-button
variant="subtle"
(click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()"
>
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
</app-button>
</div>
<div class="letter">
@for (section of brief().sections; track section.sectionKey) {
<section>
<app-heading [level]="3">{{ section.title }}</app-heading>
@for (block of section.blocks; track block.blockId) {
@for (seg of segmentsOf(block); track $index) {
@if (seg.list === 'bullet') {
<ul>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
/>
</li>
}
</ul>
} @else if (seg.list === 'number') {
<ol>
@for (para of seg.items; track $index) {
<li>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
/>
</li>
}
</ol>
} @else {
<p>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
/>
</p>
}
}
}
</section>
}
</div>
`,
})
export class LetterPreviewComponent {
brief = input.required<Brief>();
diagnostics = input<readonly Diagnostic[]>([]);
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
protected showSample = signal(false);
private today = formatDatumNl(new Date());
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
private worst = computed(() => {
const m = new Map<string, 'error' | 'warning'>();
for (const d of this.diagnostics()) {
if (!d.placeholderKey) continue;
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
}
return m;
});
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
protected sampleFor = (key: string) =>
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.today : this.labelFor(key));
}

View File

@@ -1,88 +0,0 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { Brief } from '@brief/domain/brief';
import { Diagnostic } from '@brief/domain/placeholders';
import { LetterPreviewComponent } from './letter-preview.component';
const brief: Brief = {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
drafterId: 'demo-drafter',
status: { tag: 'draft' },
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
],
sections: [
{
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
locked: true,
blocks: [
{
type: 'passage',
blockId: 'local-1',
sourcePassageId: 'p1',
sourceVersion: 1,
edited: false,
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte heer/mevrouw ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
],
},
},
],
},
{
sectionKey: 'kern',
title: 'Kern van het besluit',
required: true,
locked: false,
blocks: [
{
type: 'freeText',
blockId: 'local-2',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Wij hebben besloten om reden ' },
{ type: 'placeholder', key: 'reden_besluit' },
{ type: 'text', text: '.' },
],
},
],
},
},
],
},
],
};
const diagnostics: Diagnostic[] = [
{
severity: 'warning',
code: 'unresolved-at-send',
placeholderKey: 'reden_besluit',
location: { blockId: 'local-2', paragraphIndex: 0, nodeIndex: 1 },
message: '"Reden besluit" is nog niet ingevuld.',
},
];
const meta: Meta<LetterPreviewComponent> = {
title: 'Domein/Brief/Letter Preview',
component: LetterPreviewComponent,
args: { brief, diagnostics },
};
export default meta;
type Story = StoryObj<LetterPreviewComponent>;
export const Default: Story = {};
export const ZonderBevindingen: Story = { args: { diagnostics: [] } };

View File

@@ -0,0 +1,348 @@
import { Component, computed, input, output } from '@angular/core';
import { DatePipe } from '@angular/common';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
import { UploadState } from '@shared/upload/upload.machine';
import { Brief } from '@brief/domain/brief';
import {
MARGIN_MAX_MM,
MARGIN_MIN_MM,
Margins,
OrgTemplate,
OrgTemplateVersion,
SubOrgSummary,
} from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
const LOGO_CATEGORY = 'org-logo';
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];
/** A minimal read-only sample letter, so the admin sees the org identity in context
while editing (content itself is not the admin's to change). */
export const SAMPLE_LETTER_BRIEF: Brief = {
briefId: 'VOORBEELD-0001',
beroep: 'arts',
templateId: 'sample',
drafterId: 'sample',
status: { tag: 'draft' },
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'datum', label: 'Datum', autoResolvable: true },
],
sections: [
{
sectionKey: 'body',
title: 'Voorbeeldinhoud',
required: true,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'sample-1',
content: {
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Geachte ' },
{ type: 'placeholder', key: 'naam_zorgverlener' },
{ type: 'text', text: ',' },
],
},
{
nodes: [
{
type: 'text',
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
},
],
},
],
},
},
],
},
],
};
/**
* Organism (WP-26): the admin org-template editor. The mirror of the drafter's
* composer — the letter canvas runs in `editableRegions='template'` so the
* letterhead/signature/footer are edited in place, while the content is a read-only
* sample. Margins, logo upload, version history and the publish bar sit around it.
* Presentational: every mutation is an output the store turns into a command.
*/
@Component({
selector: 'app-org-template-editor',
imports: [
DatePipe,
HeadingComponent,
ButtonComponent,
AlertComponent,
FileInputComponent,
SingleUploadComponent,
LetterCanvasComponent,
],
styles: [
`
:host {
display: block;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: end;
gap: var(--rhc-space-max-md);
flex-wrap: wrap;
margin-block-end: var(--rhc-space-max-lg);
}
.field {
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-2xs);
}
.save {
color: var(--rhc-color-foreground-subtle);
font-size: 0.9em;
}
.section {
margin-block-start: var(--rhc-space-max-xl);
}
.margins {
display: flex;
gap: var(--rhc-space-max-md);
flex-wrap: wrap;
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-md);
}
.margins input {
width: 6rem;
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-sm);
}
.history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--rhc-space-max-md);
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
padding-block-end: var(--rhc-space-max-sm);
}
.bar {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-md);
align-items: center;
margin-block-start: var(--rhc-space-max-xl);
}
.published {
color: var(--rhc-color-foreground-subtle);
}
`,
],
template: `
<div class="toolbar">
<label class="field">
<span>{{ subOrgLabel() }}</span>
<select class="form-select" (change)="onSelectSubOrg($event)">
@for (o of subOrgs(); track o.subOrgId) {
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
{{ o.orgName }}
</option>
}
</select>
</label>
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
</div>
<app-letter-canvas
[brief]="sampleBrief()"
[orgTemplate]="draft()"
[logoUrl]="logoUrl()"
editableRegions="template"
(templateEdit)="templateEdit.emit($event)"
/>
<fieldset class="section margins">
<legend>{{ marginsLegend() }}</legend>
@for (edge of edges; track edge) {
<label class="field">
<span>{{ edgeLabel(edge) }}</span>
<input
class="form-control"
type="number"
[min]="MIN"
[max]="MAX"
[value]="draft().margins[edge]"
(input)="onMargin(edge, $event)"
/>
</label>
}
</fieldset>
<section class="section">
<app-heading [level]="3">{{ logoHeading() }}</app-heading>
@if (logoCategory()) {
<app-file-input
inputId="org-logo-input"
[accept]="logoCategory()!.acceptedTypes"
[maxSizeMb]="logoCategory()!.maxSizeMb"
[label]="logoHeading()"
(filesSelected)="logoSelected.emit($event)"
/>
}
@if (logoRejection()) {
<app-alert type="error">{{ logoRejection() }}</app-alert>
}
@if (logoUploads().length) {
<ul class="file-list">
@for (u of logoUploads(); track u.localId) {
<li
app-single-upload
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="logoRemoved.emit(u.localId)"
(retry)="logoRetry.emit(u.localId)"
></li>
}
</ul>
}
</section>
<section class="section">
<app-heading [level]="3">{{ historyHeading() }}</app-heading>
@if (history().length === 0) {
<p class="published">{{ noHistory() }}</p>
} @else {
<ul class="history-list">
@for (v of history(); track v.version) {
<li class="history-row">
<span>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span>
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
{{ rollbackLabel() }}
</app-button>
</li>
}
</ul>
}
</section>
<div class="bar">
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span>
@if (pendingPublish()) {
<app-alert type="warning">{{ impactText() }}</app-alert>
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()">
{{ confirmLabel() }}
</app-button>
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()">
{{ cancelLabel() }}
</app-button>
} @else {
<app-button
variant="primary"
[disabled]="!draftValid() || busy()"
(click)="requestPublish.emit()"
>
{{ publishLabel() }}
</app-button>
@if (!draftValid()) {
<span class="published">{{ invalidHint() }}</span>
}
}
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()">
{{ proefbriefLabel() }}
</app-button>
</div>
`,
})
export class OrgTemplateEditorComponent {
draft = input.required<OrgTemplate>();
logoUrl = input<string | null>(null);
uploadState = input.required<UploadState>();
subOrgs = input<readonly SubOrgSummary[]>([]);
selectedSubOrgId = input<string | null>(null);
history = input<readonly OrgTemplateVersion[]>([]);
publishedVersion = input(0);
unsentBriefs = input(0);
draftValid = input(false);
busy = input(false);
pendingPublish = input(false);
saveText = input('');
sampleBrief = input<Brief>(SAMPLE_LETTER_BRIEF);
previewUrlFor = input<(documentId: string) => string | undefined>();
selectSubOrg = output<string>();
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
marginEdit = output<{ edge: keyof Margins; value: number }>();
logoSelected = output<File[]>();
logoRemoved = output<string>();
logoRetry = output<string>();
requestPublish = output<void>();
confirmPublish = output<void>();
cancelPublish = output<void>();
rollback = output<number>();
proefbrief = output<void>();
protected readonly edges = EDGES;
protected readonly MIN = MARGIN_MIN_MM;
protected readonly MAX = MARGIN_MAX_MM;
protected logoCategory = computed(() =>
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
);
protected logoUploads = computed(() =>
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
);
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
protected onSelectSubOrg(event: Event) {
this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
}
protected onMargin(edge: keyof Margins, event: Event) {
const value = (event.target as HTMLInputElement).valueAsNumber;
if (Number.isFinite(value)) this.marginEdit.emit({ edge, value });
}
protected edgeLabel(edge: keyof Margins): string {
switch (edge) {
case 'topMm':
return $localize`:@@orgTemplate.margin.top:Boven (mm)`;
case 'rightMm':
return $localize`:@@orgTemplate.margin.right:Rechts (mm)`;
case 'bottomMm':
return $localize`:@@orgTemplate.margin.bottom:Onder (mm)`;
case 'leftMm':
return $localize`:@@orgTemplate.margin.left:Links (mm)`;
}
}
protected impactText = computed(() =>
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
);
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
protected marginsLegend = input($localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`);
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);
protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);
protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);
protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);
protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);
protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);
protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);
protected invalidHint = input(
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
);
}

View File

@@ -0,0 +1,81 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { OrgTemplateEditorComponent } from './org-template-editor.component';
import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template';
import { UploadState, initialUpload } from '@shared/upload/upload.machine';
const draft: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 3,
};
const subOrgs: SubOrgSummary[] = [
{ subOrgId: 'cibg-registers', orgName: 'CIBG — Registers', publishedVersion: 3 },
{ subOrgId: 'cibg-vakbekwaamheid', orgName: 'CIBG — Vakbekwaamheid', publishedVersion: 1 },
];
const history: OrgTemplateVersion[] = [
{ version: 3, publishedAt: '2026-06-20', template: draft },
{ version: 2, publishedAt: '2026-05-11', template: draft },
];
const uploadWithCategory: UploadState = {
...initialUpload,
categories: [
{
categoryId: 'org-logo',
label: 'Logo',
description: 'Logo van de organisatie',
required: false,
acceptedTypes: ['image/png', 'image/jpeg'],
maxSizeMb: 2,
multiple: false,
allowPostDelivery: false,
},
],
};
const meta: Meta<OrgTemplateEditorComponent> = {
title: 'Domein/Brief/Org Template Editor',
component: OrgTemplateEditorComponent,
args: {
draft,
logoUrl: null,
uploadState: uploadWithCategory,
subOrgs,
selectedSubOrgId: 'cibg-registers',
history,
publishedVersion: 3,
unsentBriefs: 4,
draftValid: true,
busy: false,
pendingPublish: false,
saveText: '',
},
};
export default meta;
type Story = StoryObj<OrgTemplateEditorComponent>;
export const Editing: Story = {};
export const PublishConfirm: Story = {
args: { pendingPublish: true },
};
export const Invalid: Story = {
args: {
draft: { ...draft, orgName: '', margins: { ...draft.margins, topMm: 5 } },
draftValid: false,
},
};
export const NoHistory: Story = {
args: { history: [], publishedVersion: 0 },
};

View File

@@ -0,0 +1,128 @@
import { Component, computed, effect, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { AccessStore } from '@shared/application/access.store';
import { UploadAdapter } from '@shared/upload/upload.adapter';
import { OrgTemplateStore } from '@brief/application/org-template.store';
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default
capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor
for admins. Loads once the capability resolves; wires store commands to the organism. */
@Component({
selector: 'app-org-template-page',
imports: [
PageShellComponent,
AlertComponent,
ButtonComponent,
...ASYNC,
OrgTemplateEditorComponent,
],
styles: [
`
.save {
color: var(--rhc-color-foreground-subtle);
font-size: 0.9em;
}
`,
],
template: `
<app-page-shell [heading]="heading" [intro]="intro" backLink="/brief">
@if (store.lastError(); as err) {
<app-alert type="error">{{ err }}</app-alert>
}
@if (!access.ready()) {
<!-- wait for /me before deciding — avoids flashing the denial to an admin -->
} @else if (!canEdit()) {
<app-alert type="error">{{ deniedText }}</app-alert>
} @else {
<app-async [data]="store.remoteData()">
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
</ng-template>
<ng-template appAsyncLoaded>
@if (store.draft(); as draft) {
<app-org-template-editor
[draft]="draft"
[logoUrl]="store.logoUrl()"
[uploadState]="store.uploadState()"
[subOrgs]="store.subOrgs()"
[selectedSubOrgId]="store.selectedSubOrgId()"
[history]="store.history()"
[publishedVersion]="store.publishedVersion()"
[unsentBriefs]="store.unsentBriefs()"
[draftValid]="store.draftValid()"
[busy]="store.busy()"
[pendingPublish]="store.pendingPublish()"
[saveText]="saveText()"
[previewUrlFor]="previewUrlFor"
(selectSubOrg)="store.selectSubOrg($event)"
(templateEdit)="store.edit({ tag: 'FieldEdited', field: $event.field, value: $event.value })"
(marginEdit)="store.edit({ tag: 'MarginEdited', edge: $event.edge, value: $event.value })"
(logoSelected)="store.onLogoSelected($event)"
(logoRemoved)="store.onLogoRemoved($event)"
(logoRetry)="store.onLogoRetry($event)"
(requestPublish)="store.requestPublish()"
(confirmPublish)="store.confirmPublish()"
(cancelPublish)="store.cancelPublish()"
(rollback)="store.rollback($event)"
(proefbrief)="store.proefbrief()"
/>
}
</ng-template>
</app-async>
}
</app-page-shell>
`,
})
export class OrgTemplatePage {
protected store = inject(OrgTemplateStore);
protected access = inject(AccessStore);
private uploadAdapter = inject(UploadAdapter);
protected canEdit = computed(() => this.access.can('orgtemplate:edit'));
protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);
protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;
protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;
protected deniedText = $localize`:@@orgTemplate.page.denied:U hebt geen rechten om organisatiesjablonen te beheren.`;
protected failedText = $localize`:@@orgTemplate.page.failed:Het sjabloon kon niet worden geladen.`;
protected retryText = $localize`:@@orgTemplate.page.retry:Opnieuw proberen`;
private savingText = $localize`:@@orgTemplate.page.saving:Concept opslaan…`;
private savedText = $localize`:@@orgTemplate.page.saved:Concept opgeslagen`;
private saveErrorText = $localize`:@@orgTemplate.page.saveError:Opslaan mislukt`;
protected saveText = computed(() => {
switch (this.store.saveState().tag) {
case 'Saving':
return this.savingText;
case 'Saved':
return this.savedText;
case 'Error':
return this.saveErrorText;
default:
return '';
}
});
private loadRequested = false;
constructor() {
// Load once the capability resolves to `allowed` (a 403 GET would be wasted
// otherwise). Depends only on `canEdit()` + a plain flag — never on the store
// model, so dispatching `Loading` inside `load()` can't retrigger this effect.
effect(() => {
if (this.canEdit() && !this.loadRequested) {
this.loadRequested = true;
void this.store.load();
}
});
}
protected reload() {
void this.store.load();
}
}

View File

@@ -14,6 +14,7 @@ import { LibraryPassage } from '@brief/domain/brief';
`
:host {
display: block;
background: var(--rhc-color-wit);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-md);
@@ -35,6 +36,7 @@ import { LibraryPassage } from '@brief/domain/brief';
@for (p of passages(); track p.passageId) {
<li>
<app-checkbox
[checkboxId]="'passage-' + p.passageId"
[label]="p.label"
[ngModel]="!!checked()[p.passageId]"
(ngModelChange)="set(p.passageId, $event)"

View File

@@ -33,4 +33,11 @@ export class AccessStore {
const rd = this.capabilities();
return rd.tag === 'Success' && rd.value.includes(capability);
}
/** True once `/me` has resolved (success or failure) — lets a page-level gate tell
"still loading" apart from "denied", so an admin doesn't flash the denial alert. */
readonly ready = computed(() => {
const tag = this.capabilities().tag;
return tag === 'Success' || tag === 'Failure';
});
}

View File

@@ -2,4 +2,4 @@
* A stable, namespaced capability string (PRD-0002 §5a), e.g. `brief:approve`.
* Server-resolved and opaque to the FE — never derived from a role client-side.
*/
export type Capability = 'brief:approve' | 'brief:reject' | 'brief:send';
export type Capability = 'brief:approve' | 'brief:reject' | 'brief:send' | 'orgtemplate:edit';

View File

@@ -1,7 +1,8 @@
/**
* The two-person letter workflow's role: who is acting, a drafter or an approver.
* The letter workflow's acting role: drafter or approver for the two-person
* compose/review flow, admin for org-template management (WP-23, Brief v2).
* A pure domain type (no framework, no reading mechanism) — the `?role=` reader and
* the X-Role header live in shared/infrastructure/role.ts. Consumers (brief.store,
* letter-composer) depend on this type, not on how the role is obtained.
*/
export type Role = 'drafter' | 'approver';
export type Role = 'drafter' | 'approver' | 'admin';

View File

@@ -1279,6 +1279,257 @@ export class ApiClient {
}
return Promise.resolve<BriefViewDto>(null as any);
}
/**
* @return OK
*/
orgTemplates(): Promise<SubOrgSummaryDto[]> {
let url_ = this.baseUrl + "/api/v1/admin/org-templates";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processOrgTemplates(_response);
});
}
protected processOrgTemplates(response: Response): Promise<SubOrgSummaryDto[]> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SubOrgSummaryDto[];
return result200;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<SubOrgSummaryDto[]>(null as any);
}
/**
* @return OK
*/
orgTemplateGET(subOrgId: string): Promise<OrgTemplateAdminViewDto> {
let url_ = this.baseUrl + "/api/v1/admin/org-template/{subOrgId}";
if (subOrgId === undefined || subOrgId === null)
throw new globalThis.Error("The parameter 'subOrgId' must be defined.");
url_ = url_.replace("{subOrgId}", encodeURIComponent("" + subOrgId));
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processOrgTemplateGET(_response);
});
}
protected processOrgTemplateGET(response: Response): Promise<OrgTemplateAdminViewDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as OrgTemplateAdminViewDto;
return result200;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status === 404) {
return response.text().then((_responseText) => {
return throwException("Not Found", status, _responseText, _headers);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<OrgTemplateAdminViewDto>(null as any);
}
/**
* @return OK
*/
orgTemplatePUT(subOrgId: string, body: SaveOrgTemplateRequest): Promise<OrgTemplateAdminViewDto> {
let url_ = this.baseUrl + "/api/v1/admin/org-template/{subOrgId}";
if (subOrgId === undefined || subOrgId === null)
throw new globalThis.Error("The parameter 'subOrgId' must be defined.");
url_ = url_.replace("{subOrgId}", encodeURIComponent("" + subOrgId));
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(body);
let options_: RequestInit = {
body: content_,
method: "PUT",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processOrgTemplatePUT(_response);
});
}
protected processOrgTemplatePUT(response: Response): Promise<OrgTemplateAdminViewDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as OrgTemplateAdminViewDto;
return result200;
});
} else if (status === 400) {
return response.text().then((_responseText) => {
let result400: any = null;
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Bad Request", status, _responseText, _headers, result400);
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status === 404) {
return response.text().then((_responseText) => {
return throwException("Not Found", status, _responseText, _headers);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<OrgTemplateAdminViewDto>(null as any);
}
/**
* @return OK
*/
orgTemplatePublish(subOrgId: string): Promise<PublishOrgTemplateResponse> {
let url_ = this.baseUrl + "/api/v1/admin/org-template/{subOrgId}/publish";
if (subOrgId === undefined || subOrgId === null)
throw new globalThis.Error("The parameter 'subOrgId' must be defined.");
url_ = url_.replace("{subOrgId}", encodeURIComponent("" + subOrgId));
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "POST",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processOrgTemplatePublish(_response);
});
}
protected processOrgTemplatePublish(response: Response): Promise<PublishOrgTemplateResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as PublishOrgTemplateResponse;
return result200;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status === 404) {
return response.text().then((_responseText) => {
return throwException("Not Found", status, _responseText, _headers);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<PublishOrgTemplateResponse>(null as any);
}
/**
* @return OK
*/
orgTemplateRollback(subOrgId: string, version: number): Promise<OrgTemplateAdminViewDto> {
let url_ = this.baseUrl + "/api/v1/admin/org-template/{subOrgId}/rollback/{version}";
if (subOrgId === undefined || subOrgId === null)
throw new globalThis.Error("The parameter 'subOrgId' must be defined.");
url_ = url_.replace("{subOrgId}", encodeURIComponent("" + subOrgId));
if (version === undefined || version === null)
throw new globalThis.Error("The parameter 'version' must be defined.");
url_ = url_.replace("{version}", encodeURIComponent("" + version));
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "POST",
headers: {
"Accept": "application/json"
}
};
return this.http.fetch(url_, options_).then((_response: Response) => {
return this.processOrgTemplateRollback(_response);
});
}
protected processOrgTemplateRollback(response: Response): Promise<OrgTemplateAdminViewDto> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as OrgTemplateAdminViewDto;
return result200;
});
} else if (status === 403) {
return response.text().then((_responseText) => {
let result403: any = null;
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
return throwException("Forbidden", status, _responseText, _headers, result403);
});
} else if (status === 404) {
return response.text().then((_responseText) => {
return throwException("Not Found", status, _responseText, _headers);
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<OrgTemplateAdminViewDto>(null as any);
}
}
export interface AantekeningDto {
@@ -1356,6 +1607,7 @@ export interface BriefViewDto {
brief?: BriefDto;
availablePassages?: LibraryPassageDto[] | undefined;
decisions?: BriefDecisionsDto;
orgTemplate?: OrgTemplateDto;
}
export interface BrpAddressDto {
@@ -1467,10 +1719,44 @@ export interface ManualDiplomaPolicyDto {
policyQuestions?: PolicyQuestionDto[] | undefined;
}
export interface MarginsDto {
topMm?: number;
rightMm?: number;
bottomMm?: number;
leftMm?: number;
}
export interface MeDto {
capabilities?: string[] | undefined;
}
export interface OrgTemplateAdminViewDto {
draft?: OrgTemplateDto;
publishedVersion?: number;
history?: OrgTemplateVersionDto[] | undefined;
unsentBriefs?: number;
}
export interface OrgTemplateDto {
subOrgId?: string | undefined;
orgName?: string | undefined;
returnAddress?: string | undefined;
logoDocumentId?: string | undefined;
footerContact?: string | undefined;
footerLegal?: string | undefined;
signatureName?: string | undefined;
signatureRole?: string | undefined;
signatureClosing?: string | undefined;
margins?: MarginsDto;
version?: number;
}
export interface OrgTemplateVersionDto {
version?: number;
publishedAt?: string | undefined;
template?: OrgTemplateDto;
}
export interface ParagraphDto {
nodes?: RichTextNodeDto[] | undefined;
list?: string | undefined;
@@ -1506,6 +1792,11 @@ export interface ProblemDetails {
[key: string]: any;
}
export interface PublishOrgTemplateResponse {
version?: number;
affectedUnsentBriefs?: number;
}
export interface ReferentieResponse {
referentie?: string | undefined;
}
@@ -1551,6 +1842,16 @@ export interface SaveBriefRequest {
sections?: LetterSectionDto[] | undefined;
}
export interface SaveOrgTemplateRequest {
draft?: OrgTemplateDto;
}
export interface SubOrgSummaryDto {
subOrgId?: string | undefined;
orgName?: string | undefined;
publishedVersion?: number;
}
export interface SubmitApplicationRequest {
diplomaHerkomst?: string | undefined;
uren?: number | undefined;

View File

@@ -11,6 +11,13 @@ describe('parseMe (trust boundary)', () => {
expect(parseMe({ capabilities: [] })).toEqual({ ok: true, value: [] });
});
it('recognizes the admin org-template capability (WP-23)', () => {
expect(parseMe({ capabilities: ['orgtemplate:edit'] })).toEqual({
ok: true,
value: ['orgtemplate:edit'],
});
});
it('drops unrecognized capability strings instead of rejecting the response', () => {
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
expect(r).toEqual({ ok: true, value: ['brief:approve'] });

View File

@@ -3,7 +3,12 @@ import { Result, ok, err } from '@shared/kernel/fp';
import { Capability } from '@shared/domain/capability';
import { ApiClient } from '@shared/infrastructure/api-client';
const KNOWN: readonly Capability[] = ['brief:approve', 'brief:reject', 'brief:send'];
const KNOWN: readonly Capability[] = [
'brief:approve',
'brief:reject',
'brief:send',
'orgtemplate:edit',
];
/**
* Infrastructure adapter for `GET /me` (PRD-0002 §6): the current principal's

View File

@@ -2,11 +2,15 @@ import { HttpInterceptorFn } from '@angular/common/http';
import { currentRole } from './role';
/**
* Dev-only: stamps brief requests with the current `?role=` as an `X-Role` header so
* the backend can enforce the drafter/approver rules. Only brief endpoints carry it;
* everything else is untouched.
* Dev-only: stamps role-aware requests with the current `?role=` as an `X-Role`
* header so the backend can enforce the drafter/approver/admin rules. Only the
* brief, org-template and /me endpoints carry it (WP-23 widened the set — /me must
* see the role or `AccessStore` could never learn a capability); everything else
* is untouched.
*/
const ROLE_AWARE = ['/api/v1/brief', '/api/v1/admin/org-template', '/api/v1/me'];
export const roleInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.url.includes('/api/v1/brief')) return next(req);
if (!ROLE_AWARE.some((prefix) => req.url.includes(prefix))) return next(req);
return next(req.clone({ setHeaders: { 'X-Role': currentRole() } }));
};

View File

@@ -11,7 +11,6 @@ import { Role } from '@shared/domain/role';
* longer derives permission from this value itself.
*/
export function currentRole(): Role {
return new URLSearchParams(window.location.search).get('role') === 'approver'
? 'approver'
: 'drafter';
const role = new URLSearchParams(window.location.search).get('role');
return role === 'approver' || role === 'admin' ? role : 'drafter';
}

View File

@@ -38,6 +38,12 @@ export interface XhrUploadHandle {
/** Sentinel rejection so the caller can tell a user-cancel from a real failure. */
export const UPLOAD_ABORTED = Symbol('upload-aborted');
/** Direct URL to a stored document's bytes. Pure (no injection) so a store can build
a letterhead-logo `src` without pulling `ApiClient` into its dependency graph. */
export function uploadContentUrl(documentId: string): string {
return `${environment.apiBaseUrl}/api/v1/uploads/${documentId}/content`;
}
/**
* Infrastructure: the only place upload HTTP lives. Reads (categories, status,
* delete) go through the NSwag client; the multipart POST is hand-written XHR
@@ -94,7 +100,7 @@ export class UploadAdapter {
* browser opens it. demo-* ids (dev simulation) have no bytes and 404 here.
*/
contentUrl(documentId: string): string {
return `${environment.apiBaseUrl}/api/v1/uploads/${documentId}/content`;
return uploadContentUrl(documentId);
}
/**

View File

@@ -10,6 +10,9 @@
url(../fonts|icons|images) refs resolve against the vendored folder at runtime.
Licensed Rijksoverheid fonts are not used — styles.scss overrides the stack to system-ui. -->
<link rel="stylesheet" href="cibg-huisstijl/css/huisstijl.min.css" />
<!-- The letter-rendering contract (WP-24): shared verbatim with the backend's
HTML preview renderer (WP-25 inlines this same file) — keep it self-contained. -->
<link rel="stylesheet" href="letter.css" />
</head>
<!-- brand--cibg activates CIBG's official palette: robijn layout chrome + lintblauw accents
(without it, --ro-layout falls back to the blue default). -->