diff --git a/backend/src/BigRegister.Api/Data/BriefStore.cs b/backend/src/BigRegister.Api/Data/BriefStore.cs
index ea23f4c..4c3fd42 100644
--- a/backend/src/BigRegister.Api/Data/BriefStore.cs
+++ b/backend/src/BigRegister.Api/Data/BriefStore.cs
@@ -1,5 +1,6 @@
using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization;
+using BigRegister.Domain.Letters;
using Microsoft.EntityFrameworkCore;
namespace BigRegister.Api.Data;
@@ -26,6 +27,9 @@ public sealed class BriefEntity
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);
}
@@ -110,6 +114,10 @@ public static class BriefStore
// 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);
}
diff --git a/backend/src/BigRegister.Api/Data/Migrations/20260705101743_ArchivedHtml.Designer.cs b/backend/src/BigRegister.Api/Data/Migrations/20260705101743_ArchivedHtml.Designer.cs
new file mode 100644
index 0000000..87103ed
--- /dev/null
+++ b/backend/src/BigRegister.Api/Data/Migrations/20260705101743_ArchivedHtml.Designer.cs
@@ -0,0 +1,226 @@
+//
+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
+ {
+ ///
+ 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("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AutoApprovable")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DocumentIds")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Draft")
+ .HasColumnType("TEXT");
+
+ b.Property("Owner")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Reden")
+ .HasColumnType("TEXT");
+
+ b.Property("Referentie")
+ .HasColumnType("TEXT");
+
+ b.Property("StepCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("StepIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property("Submitted")
+ .HasColumnType("INTEGER");
+
+ b.Property("SubmittedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Applications");
+ });
+
+ modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Actor")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("At")
+ .HasColumnType("TEXT");
+
+ b.Property("CategoryId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("DocumentId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("AuditEntries");
+ });
+
+ modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
+ {
+ b.Property("BriefId")
+ .HasColumnType("TEXT");
+
+ b.Property("ArchivedHtml")
+ .HasColumnType("TEXT");
+
+ b.Property("Beroep")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("DrafterId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Owner")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Placeholders")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Sections")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("SentOrgTemplateVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("SubOrgId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("TemplateId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("BriefId");
+
+ b.HasIndex("Owner")
+ .IsUnique();
+
+ b.ToTable("Briefs");
+ });
+
+ modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
+ {
+ b.Property("SubOrgId")
+ .HasColumnType("TEXT");
+
+ b.Property("Draft")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("History")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("PublishedVersion")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("SubOrgId");
+
+ b.ToTable("OrgTemplates");
+ });
+
+ modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
+ {
+ b.Property("DocumentId")
+ .HasColumnType("TEXT");
+
+ b.Property("CategoryId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("BLOB");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Linked")
+ .HasColumnType("INTEGER");
+
+ b.Property("LocalId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Owner")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("SizeBytes")
+ .HasColumnType("INTEGER");
+
+ b.Property("UploadedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("WizardId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("DocumentId");
+
+ b.ToTable("Documents");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/BigRegister.Api/Data/Migrations/20260705101743_ArchivedHtml.cs b/backend/src/BigRegister.Api/Data/Migrations/20260705101743_ArchivedHtml.cs
new file mode 100644
index 0000000..84b6015
--- /dev/null
+++ b/backend/src/BigRegister.Api/Data/Migrations/20260705101743_ArchivedHtml.cs
@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BigRegister.Api.Data.Migrations
+{
+ ///
+ public partial class ArchivedHtml : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "ArchivedHtml",
+ table: "Briefs",
+ type: "TEXT",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "ArchivedHtml",
+ table: "Briefs");
+ }
+ }
+}
diff --git a/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs
index b3d00f1..e89a2ac 100644
--- a/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs
+++ b/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs
@@ -104,6 +104,9 @@ namespace BigRegister.Api.Data.Migrations
b.Property("BriefId")
.HasColumnType("TEXT");
+ b.Property("ArchivedHtml")
+ .HasColumnType("TEXT");
+
b.Property("Beroep")
.IsRequired()
.HasColumnType("TEXT");
diff --git a/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs b/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs
new file mode 100644
index 0000000..160760c
--- /dev/null
+++ b/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs
@@ -0,0 +1,191 @@
+using System.Globalization;
+using System.Text;
+using BigRegister.Api.Contracts;
+using BigRegister.Api.Data;
+
+namespace BigRegister.Domain.Letters;
+
+///
+/// Server-rendered letter HTML (WP-25) — the archived, "what is sent" artifact.
+/// Mirrors the FE letter canvas' class vocabulary exactly (public/letter.css,
+/// 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.
+///
+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("");
+ sb.Append("").Append(Enc(brief.BriefId)).Append("");
+ sb.Append("");
+ sb.Append("");
+ sb.Append("");
+
+ // --- letterhead ---
+ sb.Append("
");
+ if (template.LogoDocumentId is { } logoId && DocumentStore.Get(logoId) is { } logo)
+ {
+ sb.Append("
.Append(logo.ContentType))
");
+ }
+ sb.Append("
").Append(Enc(template.OrgName)).Append("
");
+ sb.Append("
").Append(EncLines(template.ReturnAddress)).Append("");
+ sb.Append("
").Append(EncLines(RecipientPlaceholder)).Append("");
+ sb.Append("
");
+ sb.Append("- Ons kenmerk
- ").Append(Enc(brief.BriefId)).Append("
");
+ sb.Append("- Datum
- ").Append(Enc(FormatDatumNl(at))).Append("
");
+ sb.Append("
");
+
+ // --- body: the case-type template's sections ---
+ sb.Append("
");
+ foreach (var section in brief.Sections)
+ {
+ sb.Append("").Append(Enc(section.Title)).Append("
");
+ foreach (var block in section.Blocks)
+ RenderParagraphs(sb, block.Content.Paragraphs, defs);
+ sb.Append("");
+ }
+ sb.Append("
");
+
+ // --- signature ---
+ sb.Append("
");
+ sb.Append("
").Append(Enc(template.SignatureClosing)).Append("
");
+ sb.Append("
").Append(Enc(template.SignatureName)).Append("
");
+ sb.Append("
").Append(Enc(template.SignatureRole)).Append("
");
+ sb.Append("
");
+
+ // --- footer ---
+ sb.Append("");
+
+ if (watermark) sb.Append("
VOORBEELD
");
+
+ sb.Append("
");
+ 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 paragraphs, IReadOnlyDictionary defs)
+ {
+ string? openList = null;
+ foreach (var para in paragraphs)
+ {
+ if (para.List != openList)
+ {
+ if (openList is not null) sb.Append(openList == "bullet" ? "" : "");
+ if (para.List is not null) sb.Append(para.List == "bullet" ? "" : "");
+ openList = para.List;
+ }
+ sb.Append(openList is null ? "" : "
- ");
+ foreach (var node in para.Nodes) RenderNode(sb, node, defs);
+ sb.Append(openList is null ? "
" : "");
+ }
+ if (openList is not null) sb.Append(openList == "bullet" ? "" : "");
+ }
+
+ private static void RenderNode(
+ StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary defs)
+ {
+ switch (node.Type)
+ {
+ case "text":
+ sb.Append(Enc(node.Text ?? ""));
+ break;
+ case "lineBreak":
+ sb.Append("
");
+ 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", "
");
+
+ // 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;
+ }
+ """;
+}
diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs
index f269cc3..80b2f58 100644
--- a/backend/src/BigRegister.Api/Program.cs
+++ b/backend/src/BigRegister.Api/Program.cs
@@ -346,6 +346,31 @@ api.MapPost("/brief/send", (HttpContext ctx) =>
.Produces()
.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.
diff --git a/backend/tests/BigRegister.Tests/BigRegister.Tests.csproj b/backend/tests/BigRegister.Tests/BigRegister.Tests.csproj
index d355225..d3e56d6 100644
--- a/backend/tests/BigRegister.Tests/BigRegister.Tests.csproj
+++ b/backend/tests/BigRegister.Tests/BigRegister.Tests.csproj
@@ -23,4 +23,10 @@
+
+
+ PreserveNewest
+
+
+
\ No newline at end of file
diff --git a/backend/tests/BigRegister.Tests/LetterHtml.golden.html b/backend/tests/BigRegister.Tests/LetterHtml.golden.html
new file mode 100644
index 0000000..ae16a0a
--- /dev/null
+++ b/backend/tests/BigRegister.Tests/LetterHtml.golden.html
@@ -0,0 +1,215 @@
+golden-brief-1BIG-register
Retouradres: Postbus 00000, 2500 AA Den HaagAdres van de geadresseerde
(wordt ingevuld bij verzending)- Ons kenmerk
- golden-brief-1
- Datum
- 5 juli 2026
Aanhef
Geachte heer/mevrouw Dr. A. (Anna) de Vries,
Kern van het besluit
Op
- Eerste punt: [NOG IN TE VULLEN: Reden besluit]
- Tweede punt
Slot
Met vriendelijke groet,
Met vriendelijke groet,
A. de Vries
Hoofd Registratie, BIG-register
VOORBEELD
\ No newline at end of file
diff --git a/backend/tests/BigRegister.Tests/LetterHtmlTests.cs b/backend/tests/BigRegister.Tests/LetterHtmlTests.cs
new file mode 100644
index 0000000..a6415c4
--- /dev/null
+++ b/backend/tests/BigRegister.Tests/LetterHtmlTests.cs
@@ -0,0 +1,102 @@
+using System.Text.RegularExpressions;
+using BigRegister.Api.Contracts;
+using BigRegister.Api.Data;
+using BigRegister.Domain.Letters;
+
+namespace BigRegister.Tests;
+
+///
+/// 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.
+///
+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
+ {
+ 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
+ {
+ 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
+ {
+ 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));
+ }
+}
diff --git a/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs b/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs
new file mode 100644
index 0000000..111b058
--- /dev/null
+++ b/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs
@@ -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;
+
+///
+/// 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.
+///
+public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFixture
+{
+ 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("("/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("
())!.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("
{
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
> => Promise.resolve({ ok: true, value: view }),
+ });
+ await store.load();
+ const blob = new Blob([''], { 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> => 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.');
+ });
+});
diff --git a/src/app/brief/application/brief.store.ts b/src/app/brief/application/brief.store.ts
index b9a3bf2..1271d0a 100644
--- a/src/app/brief/application/brief.store.ts
+++ b/src/app/brief/application/brief.store.ts
@@ -12,6 +12,7 @@ import {
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';
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
instead of a busy boolean + a nullable error sitting side by side. */
@@ -35,6 +36,7 @@ type LoadedBriefState = Extract;
@Injectable({ providedIn: 'root' })
export class BriefStore {
private adapter = inject(BriefAdapter);
+ private previewAdapter = inject(LetterPreviewAdapter);
private store = createStore(initial, reduce);
readonly model = this.store.model;
@@ -147,6 +149,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>) {
diff --git a/src/app/brief/infrastructure/letter-preview.adapter.ts b/src/app/brief/infrastructure/letter-preview.adapter.ts
new file mode 100644
index 0000000..f474201
--- /dev/null
+++ b/src/app/brief/infrastructure/letter-preview.adapter.ts
@@ -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> {
+ 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 {
+ try {
+ return problemDetail(await res.json(), PREVIEW_FAILED);
+ } catch {
+ return PREVIEW_FAILED;
+ }
+}
diff --git a/src/app/brief/ui/brief.page.ts b/src/app/brief/ui/brief.page.ts
index 7270afb..59ec5ed 100644
--- a/src/app/brief/ui/brief.page.ts
+++ b/src/app/brief/ui/brief.page.ts
@@ -63,6 +63,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
(approve)="store.approve()"
(reject)="store.reject($event)"
(send)="store.send()"
+ (preview)="store.previewLetter()"
/>
}
}
diff --git a/src/app/brief/ui/letter-composer/letter-composer.component.ts b/src/app/brief/ui/letter-composer/letter-composer.component.ts
index da72cb7..1415b6b 100644
--- a/src/app/brief/ui/letter-composer/letter-composer.component.ts
+++ b/src/app/brief/ui/letter-composer/letter-composer.component.ts
@@ -41,6 +41,11 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
flex-wrap: wrap;
margin-block-end: var(--rhc-space-max-lg);
}
+ .head-end {
+ display: flex;
+ align-items: center;
+ gap: var(--rhc-space-max-md);
+ }
.panel {
margin-block: var(--rhc-space-max-xl);
}
@@ -56,7 +61,12 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
template: `
{{ title() }}
-
+
+
{{
+ previewLabel()
+ }}
+
+
@if (status() === 'rejected') {
@@ -143,9 +153,11 @@ export class LetterComposerComponent {
approve = output();
reject = output();
send = output();
+ preview = output();
locate = output();
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(