From 1bb9383344bf76397e793d3613d60744f63294eb Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Sun, 5 Jul 2026 12:56:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(fp):=20WP-25=20=E2=80=94=20server-rendered?= =?UTF-8?q?=20letter=20HTML=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/BigRegister.Api/Data/BriefStore.cs | 8 + .../20260705101743_ArchivedHtml.Designer.cs | 226 ++++++++++++++++++ .../Migrations/20260705101743_ArchivedHtml.cs | 28 +++ .../Migrations/AppDbContextModelSnapshot.cs | 3 + .../Domain/Letters/LetterHtml.cs | 191 +++++++++++++++ backend/src/BigRegister.Api/Program.cs | 25 ++ .../BigRegister.Tests.csproj | 6 + .../BigRegister.Tests/LetterHtml.golden.html | 215 +++++++++++++++++ .../BigRegister.Tests/LetterHtmlTests.cs | 102 ++++++++ .../BigRegister.Tests/PreviewEndpointTests.cs | 96 ++++++++ docker-compose.yml | 5 + docs/backlog/WP-25-letter-preview-html.md | 12 +- src/app/brief/application/brief.store.spec.ts | 43 +++- src/app/brief/application/brief.store.ts | 16 ++ .../infrastructure/letter-preview.adapter.ts | 37 +++ src/app/brief/ui/brief.page.ts | 1 + .../letter-composer.component.ts | 14 +- 17 files changed, 1020 insertions(+), 8 deletions(-) create mode 100644 backend/src/BigRegister.Api/Data/Migrations/20260705101743_ArchivedHtml.Designer.cs create mode 100644 backend/src/BigRegister.Api/Data/Migrations/20260705101743_ArchivedHtml.cs create mode 100644 backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs create mode 100644 backend/tests/BigRegister.Tests/LetterHtml.golden.html create mode 100644 backend/tests/BigRegister.Tests/LetterHtmlTests.cs create mode 100644 backend/tests/BigRegister.Tests/PreviewEndpointTests.cs create mode 100644 src/app/brief/infrastructure/letter-preview.adapter.ts 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("\"\""); + } + 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("
"); + sb.Append("
").Append(EncLines(template.FooterContact)).Append("
"); + sb.Append("
").Append(Enc(template.FooterLegal)).Append("
"); + 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 ? "

      " : "

    1. "); + foreach (var node in para.Nodes) RenderNode(sb, node, defs); + sb.Append(openList is null ? "

      " : "
    2. "); + } + 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-1

BIG-register

Retouradres: Postbus 00000, 2500 AA Den Haag
Adres 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

\ 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(