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,9 +1,10 @@
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 { Brief, BriefDecisions } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { BriefStore } from './brief.store';
const decisions: BriefDecisions = {
@@ -103,3 +104,43 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
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 { 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<BriefState, { tag: 'loaded' }>;
@Injectable({ providedIn: 'root' })
export class BriefStore {
private adapter = inject(BriefAdapter);
private previewAdapter = inject(LetterPreviewAdapter);
private store = createStore<BriefState, BriefMsg>(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<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()"
(reject)="store.reject($event)"
(send)="store.send()"
(preview)="store.previewLetter()"
/>
}
}

View File

@@ -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: `
<div class="head">
<app-heading [level]="2">{{ title() }}</app-heading>
<app-status-badge [label]="statusLabel()" [color]="statusColor()" />
<div class="head-end">
<app-button variant="subtle" [disabled]="busy()" (click)="preview.emit()">{{
previewLabel()
}}</app-button>
<app-status-badge [label]="statusLabel()" [color]="statusColor()" />
</div>
</div>
@if (status() === 'rejected') {
@@ -143,9 +153,11 @@ export class LetterComposerComponent {
approve = output<void>();
reject = output<string>();
send = output<void>();
preview = output<void>();
locate = output<Diagnostic>();
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(