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[] 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

View File

@@ -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

View File

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

View File

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

View File

@@ -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