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>
This commit is contained in:
2026-07-05 12:56:36 +02:00
parent c07a33ee3e
commit 1bb9383344
17 changed files with 1020 additions and 8 deletions

View File

@@ -1,5 +1,6 @@
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization; using BigRegister.Domain.Authorization;
using BigRegister.Domain.Letters;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
@@ -26,6 +27,9 @@ public sealed class BriefEntity
public string SubOrgId { get; set; } = OrgTemplateSeed.Registers; public string SubOrgId { get; set; } = OrgTemplateSeed.Registers;
/// Pinned at send: sent letters are immutable, a republish never re-themes them. /// Pinned at send: sent letters are immutable, a republish never re-themes them.
public int? SentOrgTemplateVersion { get; set; } 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); 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 // 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. // its appearance is frozen — republishing the template touches unsent briefs only.
e.SentOrgTemplateVersion = OrgTemplateStore.PublishedVersionOf(e.SubOrgId); 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(); db.SaveChanges();
return (Outcome.Ok, e); return (Outcome.Ok, e);
} }

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") b.Property<string>("BriefId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ArchivedHtml")
.HasColumnType("TEXT");
b.Property<string>("Beroep") b.Property<string>("Beroep")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");

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

@@ -346,6 +346,31 @@ api.MapPost("/brief/send", (HttpContext ctx) =>
.Produces<BriefViewDto>() .Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status409Conflict); .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) => api.MapPost("/brief/reset", (HttpContext ctx) =>
{ {
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only. // Demo "start over": recreate a fresh draft. No guards — showcase affordance only.

View File

@@ -23,4 +23,10 @@
<ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" /> <ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Include="LetterHtml.golden.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project> </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,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 - ./backend:/src:z
- api-bin:/src/src/BigRegister.Api/bin - api-bin:/src/src/BigRegister.Api/bin
- api-obj:/src/src/BigRegister.Api/obj - 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: ports:
- '5000:5000' - '5000:5000'

View File

@@ -1,6 +1,6 @@
# WP-25 — Server-rendered letter preview (HTML; PDF seam deferred) # WP-25 — Server-rendered letter preview (HTML; PDF seam deferred)
Status: todo Status: done
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview) Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why ## Why
@@ -63,13 +63,13 @@ same composition is archived with the brief, making sent letters immutable.
## Acceptance criteria ## Acceptance criteria
- [ ] Preview opens the composed print-ready letter in a new tab; browser print - [x] Preview opens the composed print-ready letter in a new tab; browser print
shows correct margins via `@page`. shows correct margins via `@page`.
- [ ] Unresolved manual placeholders render `[NOG IN TE VULLEN: …]`; preview works - [x] Unresolved manual placeholders render `[NOG IN TE VULLEN: …]`; preview works
despite lint errors (only send blocks). despite lint errors (only send blocks).
- [ ] A sent brief serves its archived HTML unchanged after an org-template republish. - [x] A sent brief serves its archived HTML unchanged after an org-template republish.
- [ ] Golden + parity tests green without any browser installed. - [x] Golden + parity tests green without any browser installed.
- [ ] `swagger.json` unchanged by the two endpoints (drift check green). - [x] `swagger.json` unchanged by the two endpoints (drift check green).
## Verification ## Verification

View File

@@ -1,9 +1,10 @@
import { TestBed } from '@angular/core/testing'; 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 { Result } from '@shared/kernel/fp';
import { Brief, BriefDecisions } from '@brief/domain/brief'; import { Brief, BriefDecisions } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { BriefStore } from './brief.store'; import { BriefStore } from './brief.store';
const decisions: BriefDecisions = { const decisions: BriefDecisions = {
@@ -103,3 +104,43 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
expect(store.lastError()).toBeNull(); 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

@@ -12,6 +12,7 @@ import {
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine'; import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; 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 /** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
instead of a busy boolean + a nullable error sitting side by side. */ instead of a busy boolean + a nullable error sitting side by side. */
@@ -35,6 +36,7 @@ type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class BriefStore { export class BriefStore {
private adapter = inject(BriefAdapter); private adapter = inject(BriefAdapter);
private previewAdapter = inject(LetterPreviewAdapter);
private store = createStore<BriefState, BriefMsg>(initial, reduce); private store = createStore<BriefState, BriefMsg>(initial, reduce);
readonly model = this.store.model; readonly model = this.store.model;
@@ -147,6 +149,20 @@ export class BriefStore {
reject = (comments: string) => this.transition(() => this.adapter.reject(comments)); reject = (comments: string) => this.transition(() => this.adapter.reject(comments));
send = () => this.transition(() => this.adapter.send()); 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 // A transition: flush any pending save, call the server (authoritative), then mirror
// the returned status through the pure reducer's guarded transition. // the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, BriefView>>) { private async transition(action: () => Promise<Result<string, BriefView>>) {

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

@@ -63,6 +63,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
(approve)="store.approve()" (approve)="store.approve()"
(reject)="store.reject($event)" (reject)="store.reject($event)"
(send)="store.send()" (send)="store.send()"
(preview)="store.previewLetter()"
/> />
} }
} }

View File

@@ -41,6 +41,11 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
flex-wrap: wrap; flex-wrap: wrap;
margin-block-end: var(--rhc-space-max-lg); margin-block-end: var(--rhc-space-max-lg);
} }
.head-end {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
}
.panel { .panel {
margin-block: var(--rhc-space-max-xl); margin-block: var(--rhc-space-max-xl);
} }
@@ -56,8 +61,13 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
template: ` template: `
<div class="head"> <div class="head">
<app-heading [level]="2">{{ title() }}</app-heading> <app-heading [level]="2">{{ title() }}</app-heading>
<div class="head-end">
<app-button variant="subtle" [disabled]="busy()" (click)="preview.emit()">{{
previewLabel()
}}</app-button>
<app-status-badge [label]="statusLabel()" [color]="statusColor()" /> <app-status-badge [label]="statusLabel()" [color]="statusColor()" />
</div> </div>
</div>
@if (status() === 'rejected') { @if (status() === 'rejected') {
<app-rejection-comments mode="show" [comments]="rejectComments()" /> <app-rejection-comments mode="show" [comments]="rejectComments()" />
@@ -143,9 +153,11 @@ export class LetterComposerComponent {
approve = output<void>(); approve = output<void>();
reject = output<string>(); reject = output<string>();
send = output<void>(); send = output<void>();
preview = output<void>();
locate = output<Diagnostic>(); locate = output<Diagnostic>();
title = input($localize`:@@brief.title:Brief aan de zorgverlener`); title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`); submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`); resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
submitHint = input( submitHint = input(