diff --git a/backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs b/backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs index 1ba9aa4..6ca7f3c 100644 --- a/backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs +++ b/backend/src/BigRegister.Api/Domain/Documents/DocumentCategory.cs @@ -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" }; - /// The categories that apply to a given wizard/step. - public static IReadOnlyList 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); + + /// + /// The MAXIMAL set of categories a wizard can ever ask for. Used to validate an + /// upload POST (any real category must resolve) — + /// decides which subset is actually presented for a given set of answers. + /// + public static IReadOnlyList 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(), }; + /// + /// 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. + /// + public static IReadOnlyList CategoriesFor( + string wizardId, string? diplomaHerkomst = null, string? taalvaardigheid = null) + { + if (wizardId != "registratie") return AllCategoriesFor(wizardId); + var result = new List(); + 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); /// /// Authoritative upload validation (the client check is UX-only). Returns a diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index ff4b273..f259e2e 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -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 diff --git a/backend/swagger.json b/backend/swagger.json index ff5f6b2..ecc124d 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -296,6 +296,20 @@ "schema": { "type": "string" } + }, + { + "name": "diplomaHerkomst", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "taalvaardigheid", + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs index 68a265c..e739e59 100644 --- a/backend/tests/BigRegister.Tests/EndpointTests.cs +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -149,7 +149,8 @@ public class EndpointTests(WebApplicationFactory factory) : IClassFixtu [Fact] public async Task Categories_are_server_owned_config() { - var dto = await _client.GetFromJsonAsync("/api/v1/uploads/categories?wizardId=registratie"); + // A manual diploma requires a diploma upload; identiteit is always required. + var dto = await _client.GetFromJsonAsync("/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); } diff --git a/backend/tests/BigRegister.Tests/RuleTests.cs b/backend/tests/BigRegister.Tests/RuleTests.cs index 006c59e..8b9997a 100644 --- a/backend/tests/BigRegister.Tests/RuleTests.cs +++ b/backend/tests/BigRegister.Tests/RuleTests.cs @@ -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 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 diff --git a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index 0643390..64e1b37 100644 --- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -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(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]); diff --git a/src/app/shared/infrastructure/api-client.ts b/src/app/shared/infrastructure/api-client.ts index b3a703e..12a7983 100644 --- a/src/app/shared/infrastructure/api-client.ts +++ b/src/app/shared/infrastructure/api-client.ts @@ -448,14 +448,24 @@ export class ApiClient { } /** + * @param diplomaHerkomst (optional) + * @param taalvaardigheid (optional) * @return OK */ - categories(wizardId: string): Promise { + categories(wizardId: string, diplomaHerkomst?: string | undefined, taalvaardigheid?: string | undefined): Promise { 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 = { diff --git a/src/app/shared/ui/upload/document-upload/document-upload.component.ts b/src/app/shared/ui/upload/document-upload/document-upload.component.ts index 694ff17..9cb1283 100644 --- a/src/app/shared/ui/upload/document-upload/document-upload.component.ts +++ b/src/app/shared/ui/upload/document-upload/document-upload.component.ts @@ -16,7 +16,7 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat @if (state().categoriesError) { } @else { - @if (state().backgroundSyncAvailable === false) { + @if (state().backgroundSyncAvailable === false && state().categories.length > 0) { } @for (c of state().categories; track c.categoryId) { diff --git a/src/app/shared/upload/upload-controller.ts b/src/app/shared/upload/upload-controller.ts index 760b5a0..b51ae23 100644 --- a/src/app/shared/upload/upload-controller.ts +++ b/src/app/shared/upload/upload-controller.ts @@ -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(); - 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. diff --git a/src/app/shared/upload/upload.adapter.ts b/src/app/shared/upload/upload.adapter.ts index 0c45ace..393cb54 100644 --- a/src/app/shared/upload/upload.adapter.ts +++ b/src/app/shared/upload/upload.adapter.ts @@ -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. */ diff --git a/src/app/shared/upload/upload.machine.spec.ts b/src/app/shared/upload/upload.machine.spec.ts index 77be6b5..9a3df9b 100644 --- a/src/app/shared/upload/upload.machine.spec.ts +++ b/src/app/shared/upload/upload.machine.spec.ts @@ -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', () => { diff --git a/src/app/shared/upload/upload.machine.ts b/src/app/shared/upload/upload.machine.ts index 2868fde..62f4e0d 100644 --- a/src/app/shared/upload/upload.machine.ts +++ b/src/app/shared/upload/upload.machine.ts @@ -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 = {}; + 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 };