using System.Globalization; using System.Text; using BigRegister.Api.Contracts; using BigRegister.Api.Data; namespace BigRegister.Domain.Letters; /// /// Server-rendered letter HTML — 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, at); 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 at) { 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" ? "" : ""); } private static void RenderNode( StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary defs, string at) { 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, at)) : 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, string at) => key switch { "naam_zorgverlener" => SeedData.Registration.Naam, "big_nummer" => SeedData.Registration.BigNummer, "datum" => FormatDatumNl(at), _ => 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/project/archive/backlog/WP-25-letter-preview-html.md) 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; } """; }