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:
@@ -20,16 +20,21 @@ public static class DocumentRules
|
||||
private static readonly string[] Pdf = { "application/pdf" };
|
||||
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
|
||||
|
||||
/// <summary>The categories that apply to a given wizard/step.</summary>
|
||||
public static IReadOnlyList<DocumentCategory> CategoriesFor(string wizardId) => wizardId switch
|
||||
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
|
||||
"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[]
|
||||
{
|
||||
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),
|
||||
},
|
||||
"registratie" => new[] { Diploma, Identiteit, Taalvaardigheid },
|
||||
"herregistratie" => new[]
|
||||
{
|
||||
new DocumentCategory("werkervaring", "Bewijs van werkervaring",
|
||||
@@ -40,8 +45,27 @@ public static class DocumentRules
|
||||
_ => 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) =>
|
||||
CategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
|
||||
AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
|
||||
|
||||
/// <summary>
|
||||
/// Authoritative upload validation (the client check is UX-only). Returns a
|
||||
|
||||
@@ -88,8 +88,8 @@ api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
|
||||
// --- Document upload ---
|
||||
|
||||
// Server-owned category config per wizard. The FE renders these; it never hardcodes.
|
||||
api.MapGet("/uploads/categories", (string wizardId) =>
|
||||
new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId).Select(c => c.ToDto()).ToList()));
|
||||
api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, string? taalvaardigheid) =>
|
||||
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
|
||||
// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type
|
||||
|
||||
@@ -296,6 +296,20 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "diplomaHerkomst",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "taalvaardigheid",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
@@ -149,7 +149,8 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
|
||||
[Fact]
|
||||
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 == "identiteit" && c.AllowPostDelivery);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,33 @@ public class DocumentRuleTests
|
||||
var c = DocumentRules.Find("registratie", "diploma");
|
||||
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
|
||||
|
||||
@@ -39,6 +39,9 @@ const KANALEN = [
|
||||
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
|
||||
];
|
||||
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
|
||||
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
|
||||
@@ -213,6 +216,13 @@ export class RegistratieWizardComponent {
|
||||
wizardId: 'registratie',
|
||||
getUpload: () => this.upload(),
|
||||
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
|
||||
// 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!);
|
||||
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,
|
||||
});
|
||||
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
||||
|
||||
@@ -448,14 +448,24 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param diplomaHerkomst (optional)
|
||||
* @param taalvaardigheid (optional)
|
||||
* @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?";
|
||||
if (wizardId === undefined || wizardId === null)
|
||||
throw new globalThis.Error("The parameter 'wizardId' must be defined and cannot be null.");
|
||||
else
|
||||
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(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
|
||||
@if (state().categoriesError) {
|
||||
<app-upload-status-banner type="error" [message]="state().categoriesError!" />
|
||||
} @else {
|
||||
@if (state().backgroundSyncAvailable === false) {
|
||||
@if (state().backgroundSyncAvailable === false && state().categories.length > 0) {
|
||||
<app-upload-status-banner type="info" [message]="foregroundOnlyMessage" />
|
||||
}
|
||||
@for (c of state().categories; track c.categoryId) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { SUBMIT_FAILED } from '@shared/application/submit';
|
||||
@@ -9,6 +9,8 @@ export interface UploadControllerDeps {
|
||||
wizardId: string;
|
||||
getUpload: () => UploadState;
|
||||
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 shell = inject(UploadShellService);
|
||||
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
|
||||
// init), so these dispatches aren't wiped by a state reseed.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, inject, resource } from '@angular/core';
|
||||
import { Injectable, computed, inject, resource } from '@angular/core';
|
||||
import {
|
||||
ApiClient,
|
||||
DocumentCategoryDto,
|
||||
@@ -9,6 +9,12 @@ import { currentScenario } from '@shared/infrastructure/scenario';
|
||||
import { environment } from '../../../environments/environment';
|
||||
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. */
|
||||
export interface StatusItem {
|
||||
localId: string;
|
||||
@@ -42,8 +48,22 @@ export const UPLOAD_ABORTED = Symbol('upload-aborted');
|
||||
export class UploadAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
categoriesResource(wizardId: string) {
|
||||
return resource({ loader: () => this.client.categories(wizardId).then((r) => (r.categories ?? []).map(toCategory)) });
|
||||
/** Categories are server-owned; some depend on answers (e.g. registratie's diploma
|
||||
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. */
|
||||
|
||||
@@ -44,6 +44,15 @@ describe('CategoriesLoaded', () => {
|
||||
const s = reduceUpload({ ...initialUpload, categoriesError: 'boom' }, { type: 'CategoriesLoaded', categories: [] });
|
||||
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', () => {
|
||||
|
||||
@@ -117,9 +117,13 @@ const categoryOf = (s: UploadState, categoryId: string) => s.categories.find((c)
|
||||
export function reduceUpload(s: UploadState, m: UploadMsg): UploadState {
|
||||
switch (m.type) {
|
||||
case 'CategoriesLoaded': {
|
||||
const deliveryChannel = { ...s.deliveryChannel };
|
||||
for (const c of m.categories) deliveryChannel[c.categoryId] ??= 'digital';
|
||||
return { ...s, categories: m.categories, deliveryChannel, categoriesError: undefined };
|
||||
// Categories can change with the answers (e.g. the diploma upload disappears once
|
||||
// DUO is chosen). Drop uploads + channel choices for categories no longer present.
|
||||
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':
|
||||
return { ...s, categoriesError: m.reason };
|
||||
|
||||
Reference in New Issue
Block a user