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:
@@ -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>
|
||||
215
backend/tests/BigRegister.Tests/LetterHtml.golden.html
Normal file
215
backend/tests/BigRegister.Tests/LetterHtml.golden.html
Normal 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 · Postbus 00000, 2500 AA Den Haag · 070 000 00 00 · 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>
|
||||
102
backend/tests/BigRegister.Tests/LetterHtmlTests.cs
Normal file
102
backend/tests/BigRegister.Tests/LetterHtmlTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
96
backend/tests/BigRegister.Tests/PreviewEndpointTests.cs
Normal file
96
backend/tests/BigRegister.Tests/PreviewEndpointTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user