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 };