Registratie: answer-driven required document uploads

Categories stay server-owned (ADR-0001); the FE sends its answers to
/uploads/categories and re-fetches reactively when they change:
- Diplomabewijs required only for a handmatig diploma (DUO is verified digitally;
  nothing required before a diploma is chosen).
- Bewijs Nederlandse taalvaardigheid required only when the applicant answers "ja"
  to the nl-taalvaardigheid (B2) policy question.
CategoriesFor(wizardId, diplomaHerkomst, taalvaardigheid) decides; Find uses the
maximal set so uploads still validate. CategoriesLoaded drops orphaned uploads
when a category disappears. Also: show the foreground-only upload banner only when
there is at least one category.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 18:35:37 +02:00
parent 9822a45d9a
commit 6a61c179cd
12 changed files with 145 additions and 24 deletions

View File

@@ -20,16 +20,21 @@ public static class DocumentRules
private static readonly string[] Pdf = { "application/pdf" }; private static readonly string[] Pdf = { "application/pdf" };
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" }; private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
/// <summary>The categories that apply to a given wizard/step.</summary> private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
public static IReadOnlyList<DocumentCategory> CategoriesFor(string wizardId) => wizardId switch "Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
private static readonly DocumentCategory Identiteit = new("identiteit", "Identiteitsbewijs",
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true);
private static readonly DocumentCategory Taalvaardigheid = new("taalvaardigheid", "Bewijs Nederlandse taalvaardigheid",
"Upload een bewijs van uw Nederlandse taalvaardigheid op het vereiste niveau (B2).", true, PdfImage, 10, false, true);
/// <summary>
/// The MAXIMAL set of categories a wizard can ever ask for. Used to validate an
/// upload POST (any real category must resolve) — <see cref="CategoriesFor"/>
/// decides which subset is actually presented for a given set of answers.
/// </summary>
public static IReadOnlyList<DocumentCategory> AllCategoriesFor(string wizardId) => wizardId switch
{ {
"registratie" => new[] "registratie" => new[] { Diploma, Identiteit, Taalvaardigheid },
{
new DocumentCategory("diploma", "Diplomabewijs",
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false),
new DocumentCategory("identiteit", "Identiteitsbewijs",
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true),
},
"herregistratie" => new[] "herregistratie" => new[]
{ {
new DocumentCategory("werkervaring", "Bewijs van werkervaring", new DocumentCategory("werkervaring", "Bewijs van werkervaring",
@@ -40,8 +45,27 @@ public static class DocumentRules
_ => Array.Empty<DocumentCategory>(), _ => Array.Empty<DocumentCategory>(),
}; };
/// <summary>
/// The categories to PRESENT for a wizard, given the answers that affect required
/// documents. RULES (registratie): a diploma upload is required ONLY for a manually
/// entered diploma (a DUO diploma is verified digitally, and nothing is required
/// before a diploma is chosen); proof of Dutch taalvaardigheid is required only when
/// the applicant confirms ("ja") they meet the language requirement. Answer-agnostic
/// wizards get their full set.
/// </summary>
public static IReadOnlyList<DocumentCategory> CategoriesFor(
string wizardId, string? diplomaHerkomst = null, string? taalvaardigheid = null)
{
if (wizardId != "registratie") return AllCategoriesFor(wizardId);
var result = new List<DocumentCategory>();
if (diplomaHerkomst == "handmatig") result.Add(Diploma);
result.Add(Identiteit);
if (taalvaardigheid == "ja") result.Add(Taalvaardigheid);
return result;
}
public static DocumentCategory? Find(string wizardId, string categoryId) => public static DocumentCategory? Find(string wizardId, string categoryId) =>
CategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId); AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
/// <summary> /// <summary>
/// Authoritative upload validation (the client check is UX-only). Returns a /// Authoritative upload validation (the client check is UX-only). Returns a

View File

@@ -88,8 +88,8 @@ api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
// --- Document upload --- // --- Document upload ---
// Server-owned category config per wizard. The FE renders these; it never hardcodes. // Server-owned category config per wizard. The FE renders these; it never hardcodes.
api.MapGet("/uploads/categories", (string wizardId) => api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, string? taalvaardigheid) =>
new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId).Select(c => c.ToDto()).ToList())); new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId, diplomaHerkomst, taalvaardigheid).Select(c => c.ToDto()).ToList()));
// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded // Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded
// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type // from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type

View File

@@ -296,6 +296,20 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"name": "diplomaHerkomst",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "taalvaardigheid",
"in": "query",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {

View File

@@ -149,7 +149,8 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
[Fact] [Fact]
public async Task Categories_are_server_owned_config() public async Task Categories_are_server_owned_config()
{ {
var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/api/v1/uploads/categories?wizardId=registratie"); // A manual diploma requires a diploma upload; identiteit is always required.
var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/api/v1/uploads/categories?wizardId=registratie&diplomaHerkomst=handmatig");
Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery); Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery);
Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery); Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery);
} }

View File

@@ -31,6 +31,33 @@ public class DocumentRuleTests
var c = DocumentRules.Find("registratie", "diploma"); var c = DocumentRules.Find("registratie", "diploma");
Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024)); Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024));
} }
private static IReadOnlyList<string> Ids(string? herkomst, string? taalvaardigheid) =>
DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList();
[Fact]
public void First_load_has_no_diploma_upload() => // no diploma chosen yet
Assert.Equal(new[] { "identiteit" }, Ids(null, null));
[Fact]
public void Manual_diploma_needs_a_diploma_upload() =>
Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null));
[Fact]
public void Duo_diploma_skips_diploma_upload() =>
Assert.DoesNotContain("diploma", Ids("duo", null));
[Fact]
public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() =>
Assert.Contains("taalvaardigheid", Ids("handmatig", "ja"));
[Fact]
public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() =>
Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee"));
[Fact]
public void Find_resolves_taalvaardigheid_for_upload_validation() =>
Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid"));
} }
public class HerregistratieRuleTests public class HerregistratieRuleTests

View File

@@ -39,6 +39,9 @@ const KANALEN = [
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` }, { value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
]; ];
const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed" const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
/** The server-owned geldigheidsvraag whose "ja" answer requires a Dutch-taalvaardigheid
upload (proof of the confirmed B2 level). Stable id shared with the backend. */
const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
/** Organism: the BIG-registration wizard. All state lives in one signal driven by /** Organism: the BIG-registration wizard. All state lives in one signal driven by
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
@@ -213,6 +216,13 @@ export class RegistratieWizardComponent {
wizardId: 'registratie', wizardId: 'registratie',
getUpload: () => this.upload(), getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }), dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
// Required documents depend on answers (server decides): a diploma upload only for a
// manual diploma; a Dutch-taalvaardigheid upload only once the applicant confirms
// ("ja") the B2 language requirement.
getCategoryParams: () => ({
diplomaHerkomst: this.draft().diplomaHerkomst,
taalvaardigheid: this.draft().antwoorden[NL_TAALVAARDIGHEID_VRAAG],
}),
}); });
// Backend draft-sync (replaces sessionStorage): create a Concept once the user has // Backend draft-sync (replaces sessionStorage): create a Concept once the user has
// made progress, then debounced-sync the whole machine snapshot; resume by `?aanvraag`. // made progress, then debounced-sync the whole machine snapshot; resume by `?aanvraag`.
@@ -224,7 +234,7 @@ export class RegistratieWizardComponent {
const documentIds = deliveryRefs(s.upload).filter((r) => r.channel === 'digital' && r.documentId).map((r) => r.documentId!); const documentIds = deliveryRefs(s.upload).filter((r) => r.channel === 'digital' && r.documentId).map((r) => r.documentId!);
return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds }; return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds };
}, },
onResume: (draft) => this.dispatch({ tag: 'Seed', state: (draft as RegistratieState | null) ?? initial }), onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as RegistratieState }),
enabled: () => this.seed() === initial, enabled: () => this.seed() === initial,
}); });
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]); protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);

View File

@@ -448,14 +448,24 @@ export class ApiClient {
} }
/** /**
* @param diplomaHerkomst (optional)
* @param taalvaardigheid (optional)
* @return OK * @return OK
*/ */
categories(wizardId: string): Promise<UploadCategoriesDto> { categories(wizardId: string, diplomaHerkomst?: string | undefined, taalvaardigheid?: string | undefined): Promise<UploadCategoriesDto> {
let url_ = this.baseUrl + "/api/v1/uploads/categories?"; let url_ = this.baseUrl + "/api/v1/uploads/categories?";
if (wizardId === undefined || wizardId === null) if (wizardId === undefined || wizardId === null)
throw new globalThis.Error("The parameter 'wizardId' must be defined and cannot be null."); throw new globalThis.Error("The parameter 'wizardId' must be defined and cannot be null.");
else else
url_ += "wizardId=" + encodeURIComponent("" + wizardId) + "&"; url_ += "wizardId=" + encodeURIComponent("" + wizardId) + "&";
if (diplomaHerkomst === null)
throw new globalThis.Error("The parameter 'diplomaHerkomst' cannot be null.");
else if (diplomaHerkomst !== undefined)
url_ += "diplomaHerkomst=" + encodeURIComponent("" + diplomaHerkomst) + "&";
if (taalvaardigheid === null)
throw new globalThis.Error("The parameter 'taalvaardigheid' cannot be null.");
else if (taalvaardigheid !== undefined)
url_ += "taalvaardigheid=" + encodeURIComponent("" + taalvaardigheid) + "&";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = { let options_: RequestInit = {

View File

@@ -16,7 +16,7 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
@if (state().categoriesError) { @if (state().categoriesError) {
<app-upload-status-banner type="error" [message]="state().categoriesError!" /> <app-upload-status-banner type="error" [message]="state().categoriesError!" />
} @else { } @else {
@if (state().backgroundSyncAvailable === false) { @if (state().backgroundSyncAvailable === false && state().categories.length > 0) {
<app-upload-status-banner type="info" [message]="foregroundOnlyMessage" /> <app-upload-status-banner type="info" [message]="foregroundOnlyMessage" />
} }
@for (c of state().categories; track c.categoryId) { @for (c of state().categories; track c.categoryId) {

View File

@@ -1,5 +1,5 @@
import { DestroyRef, effect, inject } from '@angular/core'; import { DestroyRef, effect, inject } from '@angular/core';
import { UploadAdapter } from './upload.adapter'; import { CategoryParams, UploadAdapter } from './upload.adapter';
import { UploadShellService } from './upload-shell.service'; import { UploadShellService } from './upload-shell.service';
import { problemDetail } from '@shared/infrastructure/api-error'; import { problemDetail } from '@shared/infrastructure/api-error';
import { SUBMIT_FAILED } from '@shared/application/submit'; import { SUBMIT_FAILED } from '@shared/application/submit';
@@ -9,6 +9,8 @@ export interface UploadControllerDeps {
wizardId: string; wizardId: string;
getUpload: () => UploadState; getUpload: () => UploadState;
dispatch: (m: UploadMsg) => void; dispatch: (m: UploadMsg) => void;
/** Optional answer-derived params; when they change, categories re-fetch. */
getCategoryParams?: () => CategoryParams;
} }
/** /**
@@ -22,7 +24,7 @@ export function createUploadController(deps: UploadControllerDeps) {
const adapter = inject(UploadAdapter); const adapter = inject(UploadAdapter);
const shell = inject(UploadShellService); const shell = inject(UploadShellService);
const files = new Map<string, File>(); const files = new Map<string, File>();
const categoriesRes = adapter.categoriesResource(deps.wizardId); const categoriesRes = adapter.categoriesResource(deps.wizardId, deps.getCategoryParams);
// Runs after the host's `Seed`/restore microtask (effects fire in CD, not field // Runs after the host's `Seed`/restore microtask (effects fire in CD, not field
// init), so these dispatches aren't wiped by a state reseed. // init), so these dispatches aren't wiped by a state reseed.

View File

@@ -1,4 +1,4 @@
import { Injectable, inject, resource } from '@angular/core'; import { Injectable, computed, inject, resource } from '@angular/core';
import { import {
ApiClient, ApiClient,
DocumentCategoryDto, DocumentCategoryDto,
@@ -9,6 +9,12 @@ import { currentScenario } from '@shared/infrastructure/scenario';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { DocumentCategory } from './upload.machine'; import { DocumentCategory } from './upload.machine';
/** Answer-derived query params that affect which categories the server presents. */
export interface CategoryParams {
diplomaHerkomst?: string;
taalvaardigheid?: string;
}
/** One arrived/known status item from poll-on-return. */ /** One arrived/known status item from poll-on-return. */
export interface StatusItem { export interface StatusItem {
localId: string; localId: string;
@@ -42,8 +48,22 @@ export const UPLOAD_ABORTED = Symbol('upload-aborted');
export class UploadAdapter { export class UploadAdapter {
private client = inject(ApiClient); private client = inject(ApiClient);
categoriesResource(wizardId: string) { /** Categories are server-owned; some depend on answers (e.g. registratie's diploma
return resource({ loader: () => this.client.categories(wizardId).then((r) => (r.categories ?? []).map(toCategory)) }); set varies by DUO/handmatig + course language). `extra` makes the resource reactive
so it re-fetches when those answers change. */
categoriesResource(wizardId: string, extra?: () => CategoryParams) {
// Structural equality so we re-fetch only when a category-affecting answer changes,
// not on every unrelated draft edit (a computed with `equal` returns the cached
// reference while equal, so the resource sees no change).
const params = computed(
() => ({ wizardId, diplomaHerkomst: extra?.().diplomaHerkomst, taalvaardigheid: extra?.().taalvaardigheid }),
{ equal: (a, b) => a.wizardId === b.wizardId && a.diplomaHerkomst === b.diplomaHerkomst && a.taalvaardigheid === b.taalvaardigheid },
);
return resource({
params,
loader: ({ params }) =>
this.client.categories(params.wizardId, params.diplomaHerkomst, params.taalvaardigheid).then((r) => (r.categories ?? []).map(toCategory)),
});
} }
/** Poll-on-return: which client localIds have arrived at the BFF. */ /** Poll-on-return: which client localIds have arrived at the BFF. */

View File

@@ -44,6 +44,15 @@ describe('CategoriesLoaded', () => {
const s = reduceUpload({ ...initialUpload, categoriesError: 'boom' }, { type: 'CategoriesLoaded', categories: [] }); const s = reduceUpload({ ...initialUpload, categoriesError: 'boom' }, { type: 'CategoriesLoaded', categories: [] });
expect(s.categoriesError).toBeUndefined(); expect(s.categoriesError).toBeUndefined();
}); });
it('drops uploads + channel choices for categories that disappear', () => {
const s0 = select(stateWith([cat({ categoryId: 'diploma' }), cat({ categoryId: 'id' })], { deliveryChannel: { id: 'post' } }), 'diploma', 'u1');
expect(get(s0, 'u1')).toBeDefined();
// Reload with the diploma category gone (e.g. DUO chosen).
const s1 = reduceUpload(s0, { type: 'CategoriesLoaded', categories: [cat({ categoryId: 'id' })] });
expect(get(s1, 'u1')).toBeUndefined();
expect(s1.deliveryChannel).toEqual({ id: 'post' });
});
}); });
describe('CategoriesLoadFailed / BackgroundSyncAvailability', () => { describe('CategoriesLoadFailed / BackgroundSyncAvailability', () => {

View File

@@ -117,9 +117,13 @@ const categoryOf = (s: UploadState, categoryId: string) => s.categories.find((c)
export function reduceUpload(s: UploadState, m: UploadMsg): UploadState { export function reduceUpload(s: UploadState, m: UploadMsg): UploadState {
switch (m.type) { switch (m.type) {
case 'CategoriesLoaded': { case 'CategoriesLoaded': {
const deliveryChannel = { ...s.deliveryChannel }; // Categories can change with the answers (e.g. the diploma upload disappears once
for (const c of m.categories) deliveryChannel[c.categoryId] ??= 'digital'; // DUO is chosen). Drop uploads + channel choices for categories no longer present.
return { ...s, categories: m.categories, deliveryChannel, categoriesError: undefined }; const ids = new Set(m.categories.map((c) => c.categoryId));
const deliveryChannel: Record<string, DeliveryChannel> = {};
for (const c of m.categories) deliveryChannel[c.categoryId] = s.deliveryChannel[c.categoryId] ?? 'digital';
const uploads = s.uploads.filter((u) => ids.has(u.categoryId));
return { ...s, categories: m.categories, uploads, deliveryChannel, categoriesError: undefined };
} }
case 'CategoriesLoadFailed': case 'CategoriesLoadFailed':
return { ...s, categoriesError: m.reason }; return { ...s, categoriesError: m.reason };