diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs index 99ba29d..dd8bf09 100644 --- a/backend/src/BigRegister.Api/Contracts/Dtos.cs +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -71,3 +71,39 @@ public sealed record HerregistratieRequest(int Uren, IReadOnlyList DocumentIds, + string CreatedAt, string UpdatedAt, string? SubmittedAt); + +public sealed record ApplicationDetailDto( + string Id, string Type, AanvraagStatusDto Status, + System.Text.Json.JsonElement? Draft, + IReadOnlyList DocumentIds, + string CreatedAt, string UpdatedAt, string? SubmittedAt); + +public sealed record CreateApplicationRequest(string Type); + +public sealed record DraftSyncRequest( + System.Text.Json.JsonElement Draft, int StepIndex, int StepCount, + IReadOnlyList? DocumentIds = null); + +// Submit carries only the fields the server re-validates per wizard type. +public sealed record SubmitApplicationRequest( + string? DiplomaHerkomst = null, int? Uren = null, + IReadOnlyList? Documents = null); + +public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status); diff --git a/backend/src/BigRegister.Api/Contracts/Mappers.cs b/backend/src/BigRegister.Api/Contracts/Mappers.cs index 2fb139d..6d0460a 100644 --- a/backend/src/BigRegister.Api/Contracts/Mappers.cs +++ b/backend/src/BigRegister.Api/Contracts/Mappers.cs @@ -1,3 +1,4 @@ +using BigRegister.Api.Data; using BigRegister.Domain.Diplomas; using BigRegister.Domain.Documents; using BigRegister.Domain.People; @@ -34,4 +35,27 @@ public static class Mappers public static DocumentCategoryDto ToDto(this DocumentCategory c) => new( c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery); + + // Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports + // Goedgekeurd once past the processing window, else In behandeling; a manual case + // stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable + // by passing different `now` values without waiting for the wall clock. + public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) + { + if (!a.Submitted) + return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount); + if (a.Reden is not null) + return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden); + if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow) + return new("Goedgekeurd", Referentie: a.Referentie); + return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable); + } + + public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new( + a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds, + a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o")); + + public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new( + a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds, + a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o")); } diff --git a/backend/src/BigRegister.Api/Data/ApplicationStore.cs b/backend/src/BigRegister.Api/Data/ApplicationStore.cs new file mode 100644 index 0000000..97d033e --- /dev/null +++ b/backend/src/BigRegister.Api/Data/ApplicationStore.cs @@ -0,0 +1,110 @@ +using System.Text.Json; +using BigRegister.Domain.Submissions; + +namespace BigRegister.Api.Data; + +/// +/// An application (aanvraag) — the system of record the dashboard reads. A wizard +/// creates one as a Concept on its first step, syncs its draft snapshot per step, +/// then submits it into the Concept → In behandeling → Goedgekeurd/Afgewezen +/// lifecycle (ADR-0002). Status is COMPUTED ON READ (see Mappers.ToStatusDto) so +/// auto-approval is purely a function of stored timestamps — no timers, no jobs. +/// +public sealed class Aanvraag +{ + public required string Id { get; init; } + public required string Type { get; init; } // registratie | herregistratie | intake + public required string Owner { get; init; } + public JsonElement? Draft { get; set; } // opaque wizard machine snapshot (Concept only) + public int StepIndex { get; set; } + public int StepCount { get; set; } + public List DocumentIds { get; set; } = new(); + public string? Referentie { get; set; } // set on submit + public bool AutoApprovable { get; set; } // set on submit: duo (registratie) / other types + public string? Reden { get; set; } // set on submit when rejected → Afgewezen + public bool Submitted { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset? SubmittedAt { get; set; } +} + +/// +/// In-memory application store (no DB), mirrors . +/// ponytail: one global lock — fine for a single-process demo; swap for per-key +/// locks if it ever serves load. +/// +public static class ApplicationStore +{ + /// After this window an auto-approvable submission reports Goedgekeurd (computed on read). + public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8); + + private static readonly Dictionary _apps = new(); + private static readonly object _gate = new(); + + public static Aanvraag Create(string type, string owner) + { + var now = DateTimeOffset.UtcNow; + var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now }; + lock (_gate) _apps[a.Id] = a; + return a; + } + + public static Aanvraag? Get(string id, string owner) + { + lock (_gate) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null; + } + + public static IReadOnlyList List(string owner) + { + lock (_gate) return _apps.Values.Where(a => a.Owner == owner).ToList(); + } + + /// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable. + public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList? documentIds) + { + lock (_gate) + { + if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false; + a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call) + a.StepIndex = stepIndex; + a.StepCount = stepCount; + if (documentIds is not null) a.DocumentIds = documentIds.ToList(); + a.UpdatedAt = DateTimeOffset.UtcNow; + return true; + } + } + + /// Cancel a Concept: remove it and delete its (unlinked) documents. Linked docs + /// (belonging to a submitted aanvraag) are left untouched by DocumentStore. + public static bool Delete(string id, string owner) + { + List docs; + lock (_gate) + { + if (!_apps.TryGetValue(id, out var a) || a.Owner != owner) return false; + docs = a.DocumentIds.ToList(); + _apps.Remove(id); + } + foreach (var d in docs) DocumentStore.DeleteOwned(d, owner); + return true; + } + + /// Submit transition. reject != null → Afgewezen; else accepted (In behandeling, + /// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null + /// if the aanvraag is gone or already submitted (idempotency guard). + public static Aanvraag? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList? documentIds) + { + lock (_gate) + { + if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null; + a.Submitted = true; + a.SubmittedAt = DateTimeOffset.UtcNow; + a.UpdatedAt = a.SubmittedAt.Value; + a.Referentie = SubmissionRules.NewReference(); + a.AutoApprovable = autoApprovable; + a.Reden = reject; + if (documentIds is not null) a.DocumentIds = documentIds.ToList(); + return a; + } + } +} diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index e70572b..8807d49 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -146,6 +146,85 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); +// --- Applications (aanvragen): the system of record the dashboard reads. --- + +api.MapGet("/applications", () => +{ + var now = DateTimeOffset.UtcNow; + return ApplicationStore.List(DocumentStore.DemoOwner) + .OrderByDescending(a => a.UpdatedAt) + .Select(a => a.ToSummaryDto(now)).ToList(); +}); + +api.MapGet("/applications/{id}", (string id) => + ApplicationStore.Get(id, DocumentStore.DemoOwner) is { } a + ? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow)) + : Results.NotFound()) +.Produces() +.Produces(StatusCodes.Status404NotFound); + +api.MapPost("/applications", (CreateApplicationRequest req) => +{ + var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner); + return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow)); +}) +.Produces(StatusCodes.Status201Created); + +// Draft sync per step — idempotent; keep it debounced on the client (it is chatty). +api.MapPut("/applications/{id}", (string id, DraftSyncRequest req) => + ApplicationStore.SyncDraft(id, DocumentStore.DemoOwner, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds) + ? Results.NoContent() : Results.NotFound()) +.Produces(StatusCodes.Status204NoContent) +.Produces(StatusCodes.Status404NotFound); + +// Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot +// be withdrawn (out of scope — no "intrekken"). +api.MapDelete("/applications/{id}", (string id) => +{ + var a = ApplicationStore.Get(id, DocumentStore.DemoOwner); + if (a is null) return Results.NotFound(); + if (a.Submitted) + return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict); + ApplicationStore.Delete(id, DocumentStore.DemoOwner); + return Results.NoContent(); +}) +.Produces(StatusCodes.Status204NoContent) +.ProducesProblem(StatusCodes.Status409Conflict) +.Produces(StatusCodes.Status404NotFound); + +// Submit runs the server-owned rules, sets autoApprovable, and transitions the +// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case. +api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) => +{ + var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner); + if (existing is null) return Results.NotFound(); + if (existing.Submitted) + return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict); + + // Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves. + (string? reject, bool autoApprovable) = existing.Type switch + { + "registratie" => (null, req.DiplomaHerkomst == "duo"), + _ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true), + }; + + var docs = req.Documents; + if (docs is not null) + DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!)); + var documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList(); + + var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds); + if (submitted is null) return Results.Conflict(); + + app.Logger.LogInformation( + "aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}", + id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie); + return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow))); +}) +.Produces() +.ProducesProblem(StatusCodes.Status409Conflict) +.Produces(StatusCodes.Status404NotFound); + app.Run(); static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true"; diff --git a/backend/tests/BigRegister.Tests/ApplicationTests.cs b/backend/tests/BigRegister.Tests/ApplicationTests.cs new file mode 100644 index 0000000..3466f6c --- /dev/null +++ b/backend/tests/BigRegister.Tests/ApplicationTests.cs @@ -0,0 +1,136 @@ +using System.Net; +using System.Net.Http.Json; +using BigRegister.Api.Contracts; +using BigRegister.Api.Data; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace BigRegister.Tests; + +public class ApplicationTests(WebApplicationFactory factory) : IClassFixture> +{ + private readonly HttpClient _client = factory.CreateClient(); + + private async Task Create(string type = "registratie") + { + var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type }); + Assert.Equal(HttpStatusCode.Created, res.StatusCode); + return (await res.Content.ReadFromJsonAsync())!; + } + + private Task?> List() => + _client.GetFromJsonAsync>("/api/v1/applications"); + + // --- Lifecycle over HTTP --- + + [Fact] + public async Task Create_then_list_shows_a_concept_with_step_progress() + { + var a = await Create(); + await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", + new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 }); + + var mine = (await List())!.Single(x => x.Id == a.Id); + Assert.Equal("Concept", mine.Status.Tag); + Assert.Equal(1, mine.Status.StepIndex); + Assert.Equal(4, mine.Status.StepCount); + } + + [Fact] + public async Task Draft_sync_is_readable_back_from_detail() + { + var a = await Create(); + await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", + new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 }); + + var detail = await _client.GetFromJsonAsync($"/api/v1/applications/{a.Id}"); + Assert.NotNull(detail!.Draft); + Assert.Equal("verpleegkundige", detail.Draft!.Value.GetProperty("beroep").GetString()); + } + + [Fact] + public async Task Submit_duo_registratie_is_in_behandeling_and_auto() + { + var a = await Create("registratie"); + var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" }); + res.EnsureSuccessStatusCode(); + var body = (await res.Content.ReadFromJsonAsync())!; + Assert.StartsWith("BIG-2026-", body.Referentie); + Assert.Equal("InBehandeling", body.Status.Tag); + Assert.False(body.Status.Manual); // auto-approvable → not a manual case + } + + [Fact] + public async Task Submit_handmatig_registratie_succeeds_as_manual_case() + { + var a = await Create("registratie"); + var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" }); + res.EnsureSuccessStatusCode(); // no longer a 422 + var body = (await res.Content.ReadFromJsonAsync())!; + Assert.Equal("InBehandeling", body.Status.Tag); + Assert.True(body.Status.Manual); + } + + [Fact] + public async Task Submit_herregistratie_with_zero_uren_is_afgewezen() + { + var a = await Create("herregistratie"); + var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 }); + res.EnsureSuccessStatusCode(); // the submission is accepted... + var body = (await res.Content.ReadFromJsonAsync())!; + Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected + Assert.NotNull(body.Status.Reden); + } + + [Fact] + public async Task Submitting_twice_conflicts() + { + var a = await Create("registratie"); + (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); + var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" }); + Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); + } + + [Fact] + public async Task Cancel_concept_removes_it() + { + var a = await Create(); + Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode); + } + + [Fact] + public async Task Cancel_submitted_aanvraag_conflicts() + { + var a = await Create("registratie"); + (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); + Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode); + } + + // --- Auto-approval is computed on read: exercise the window boundary without waiting. --- + + private static Aanvraag Accepted(bool autoApprovable) => new() + { + Id = "x", Type = "registratie", Owner = "test", + Submitted = true, AutoApprovable = autoApprovable, Referentie = "BIG-2026-1", + SubmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, + }; + + [Fact] + public void AutoApprovable_flips_to_goedgekeurd_after_the_window() + { + var a = Accepted(autoApprovable: true); + var t0 = a.SubmittedAt!.Value; + Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag); + Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag); + } + + [Fact] + public void Manual_case_never_auto_advances() + { + var a = Accepted(autoApprovable: false); + var far = a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1); + var status = a.ToStatusDto(far); + Assert.Equal("InBehandeling", status.Tag); + Assert.True(status.Manual); + } +} diff --git a/docs/prd/0001-mijn-aanvragen-en-wizardstatus.md b/docs/prd/0001-mijn-aanvragen-en-wizardstatus.md new file mode 100644 index 0000000..dd32350 --- /dev/null +++ b/docs/prd/0001-mijn-aanvragen-en-wizardstatus.md @@ -0,0 +1,219 @@ +# PRD 0001 — "Mijn aanvragen": running wizards, application status & document preview + +Status: Proposed · Date: 2026-07-01 · Context: SSP / Zorgverlener (see ADR-0002) + +> Cross-references: **ADR-0001** (BFF-lite endpoints + decision DTOs) and **ADR-0002** (user groups as +> actors; the `Concept → In behandeling → Goedgekeurd/Afgewezen` aanvraag lifecycle). This PRD +> *materializes* that lifecycle as a backend-owned aggregate — still entirely within the Zorgverlener +> self-service context; the Behandelaar/backoffice app that advances manual cases stays a separate, +> unbuilt context. + +--- + +## 1. Problem + +A logged-in Zorgverlener cannot see what they have started or submitted, and cannot review what they +uploaded. Concretely, today: + +- Wizard drafts live in **per-wizard `sessionStorage`** (`registratie-v2`, `intake-v3`); the + **herregistratie wizard has no persistence at all**. Nothing enumerates "my in-progress applications." +- There is **no application list** — only a single optimistic boolean, `pendingHerregistratie`, in + `src/app/registratie/application/big-profile.store.ts:53`, surfaced as one dashboard alert. +- Uploaded documents **cannot be previewed or downloaded**. The backend `DocumentStore` + (`backend/src/BigRegister.Api/Data/DocumentStore.cs`) deliberately stores **metadata only, no bytes**, + and there is **no GET-content endpoint**. +- A **manual diploma is hard-rejected**: `SubmissionRules.RejectRegistratie("handmatig")` returns a + 422 (`backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs`). The product wants such a + submission to *succeed* and sit in a pending (manual-review) state instead. + +## 2. Goals + +1. Show **all running (concept) applications** as dynamic blocks at the **top of the dashboard**, so the + user immediately sees what they initiated, with **Verder gaan** (resume) and **Annuleren** (cancel → + start fresh). +2. On **re-opening** a concept wizard, let the user **preview/download** the documents they already + uploaded. +3. Show **submitted-but-unprocessed** applications in a **pending** state on the dashboard. +4. Provide **two registratie flows** that both submit successfully (never disallow submission): + - **Auto-approved** (`diplomaHerkomst = 'duo'`) → resolves to **Goedgekeurd**. + - **Manual backoffice** (`diplomaHerkomst = 'handmatig'`) → stays **In behandeling** (pending). +5. Make the backend the **system of record** for applications (concept + submitted), per the chosen + architecture. + +## 3. Non-goals / Out of scope (POC) + +- The **Behandeling/backoffice application** that advances manual aanvragen (ADR-0002). Manual cases + stay pending forever in this build. +- Real auth / multi-user — a single `DemoOwner` owns everything (matches the faked DigiD session). +- Real blob storage, virus scanning, retention — document **bytes are held in-memory** and reset when + the backend restarts. +- **Withdrawing** a submitted aanvraag ("intrekken") — cancel applies to concepts only. +- Document preview for the **dev upload simulation** (`?scenario=upload-slow|upload-fail`) — that path + returns a fake `demo-*` documentId with no bytes; preview requires a real upload. + +## 4. Personas + +Single actor: the **Zorgverlener** (healthcare professional, DigiD/BSN, self-service). Per ADR-0002, +"who may advance a manual application" is the **Behandelaar**, an actor in a *separate* backoffice +context that is not part of this app. + +## 5. Domain model — the `Aanvraag` aggregate (backend-owned) + +The backend gains an `Aanvraag` (application) aggregate — the system of record the dashboard reads. + +| Field | Type | Notes | +|---|---|---| +| `id` | string (uuid) | client-visible handle; used in the resume deep link | +| `type` | `registratie` \| `herregistratie` \| `intake` | which wizard | +| `status` | discriminated union (below) | computed on read for auto-approval | +| `draft` | opaque JSON | the wizard's persisted machine snapshot (Concept only) | +| `stepIndex`, `stepCount` | int | for the "Stap X van Y" progress on the block | +| `documentIds` | string[] | documents linked to this aanvraag | +| `referentie` | string? | set on submit (e.g. `BIG-2026-456789`) | +| `owner` | string | `DemoOwner` | +| `autoApprovable` | bool | set at submit: `diplomaHerkomst === 'duo'` (registratie); other types auto | +| `createdAt` / `updatedAt` / `submittedAt?` | timestamps | | + +### Status lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Concept: create (first wizard step) + Concept --> Concept: draft sync (per step) + Concept --> [*]: Annuleren (cancel, delete) + Concept --> InBehandeling: submit (accepted) + Concept --> Afgewezen: submit invalid (e.g. 0 uren) + InBehandeling --> Goedgekeurd: auto (duo) after processing window Δ + InBehandeling --> InBehandeling: manual (handmatig) — awaits backoffice (not built) + Goedgekeurd --> [*] + Afgewezen --> [*] +``` + +FE-mirrored discriminated union (illegal states unrepresentable, same reflex as `RemoteData`): + +```ts +type AanvraagStatus = + | { tag: 'Concept'; stepIndex: number; stepCount: number } + | { tag: 'InBehandeling'; referentie: string; manual: boolean } // manual=true → "wordt beoordeeld" + | { tag: 'Goedgekeurd'; referentie: string } + | { tag: 'Afgewezen'; referentie: string; reden: string }; +``` + +### Deterministic auto-approval (no background timer) + +Auto-approval is **computed on read**: for an `autoApprovable` aanvraag, if +`now > submittedAt + PROCESSING_WINDOW` (≈8s) the status reports **Goedgekeurd**, else **In behandeling**. +Manual (`autoApprovable === false`) aanvragen never auto-advance. This yields a visible +pending→approved transition for the auto flow and a persistent pending for the manual flow, with no +timers or background jobs — purely a function of stored timestamps. + +## 6. UX + +### Dashboard — new "Mijn aanvragen" section (top, above "Wat moet ik regelen") + +Renders `ApplicationsStore.applications()` through `` as a list of **blocks**, sorted: +Concept → In behandeling → resolved. Empty list → the section is hidden. + +Per-status block (new `aanvraag-block` component; **which** actions/badge a block shows comes from a +pure `blockActions(status)`): + +| Status | Badge | Body | Actions | +|---|---|---|---| +| **Concept** | grey "Concept" | wizard type + "Stap X van Y" | **Verder gaan** (resume), **Annuleren** (cancel) | +| **In behandeling** | amber "In behandeling" | referentie + ingediend-datum; manual → "wordt handmatig beoordeeld in de backoffice" | view/preview documents | +| **Goedgekeurd** | green "Goedgekeurd" | referentie | — | +| **Afgewezen** | red "Afgewezen" | referentie + reden | — | + +- **Verder gaan** deep-links to the wizard with `?aanvraag=`; the wizard loads the draft from the + backend and seeds its machine. +- **Annuleren** confirms, then `DELETE /applications/{id}` (removes the aanvraag and its unlinked + documents); the block disappears and the user can start a new one from the existing action cards. + +### Document preview/download + +`document-chip` (completed uploads) gains a **"Voorbeeld / Download"** affordance linking to +`GET /api/v1/uploads/{documentId}/content` — opens inline for `application/pdf` and images, downloads +otherwise. Available both inside the wizard (Concept) and from an In-behandeling block's document view. + +## 7. Backend design (ASP.NET Core, in-memory — extends existing patterns) + +- **`ApplicationStore`** (new, mirrors `DocumentStore`): in-memory dict + lock; create/get/list/ + upsert-draft/delete; `Submit()` transition; status computed on read (auto-approval window). +- **Endpoints** (`Program.cs`, `/api/v1`): + - `GET /applications` → `List` for the owner. + - `GET /applications/{id}` → `ApplicationDetailDto` (includes `draft` to resume). + - `POST /applications` → create Concept (returns id); `PUT /applications/{id}` → draft sync (idempotent). + - `DELETE /applications/{id}` → cancel Concept (cascades to unlinked documents). + - `POST /applications/{id}/submit` → runs `SubmissionRules`, sets `autoApprovable`, transitions to + In behandeling (or Afgewezen), links documents, returns `{ referentie, status }`. +- **`SubmissionRules` change**: `handmatig` no longer 422s — it yields `autoApprovable = false` + (manual/pending). Keep genuine validation rejects (0 uren → Afgewezen). +- **`DocumentStore` change**: store `byte[] Content` + `string ContentType`; add + `GET /uploads/{documentId}/content` returning bytes (Content-Disposition inline for pdf/image, + attachment otherwise). The multipart `POST /uploads` now captures bytes + content-type. +- **Tests** (`dotnet test`): lifecycle (concept→submit→auto/manual/afgewezen), auto-approve-on-read + window boundary, content endpoint returns bytes+type, cancel cascades to unlinked docs only. + +## 8. Frontend design (Angular — TEA + atomic + BFF-lite) + +- **Contracts** (`contracts/`): `ApplicationSummaryDto`, `ApplicationDetailDto`, `AanvraagStatusDto`. +- **`applications.adapter.ts`** (`infrastructure/`, the only new network surface): `httpResource` for + the list + commands (create/sync/delete/submit), with a hand-written `parseApplication*` boundary + (DTO→domain), per ADR-0001. Upload adapter gains a `contentUrl(documentId)` helper. +- **`ApplicationsStore`** (`application/`, `providedIn: 'root'`): `applications` RemoteData list rendered + via ``; optimistic begin/confirm/rollback around cancel + submit; `reload()` after + mutations — same pattern as `BigProfileStore`. Dashboard polls/reloads to reflect auto-approval. +- **Wizards**: replace `sessionStorage` persistence with a **debounced backend draft-sync** command on + each step change; open via `?aanvraag=` (load detail → seed the machine). Apply the same to the + herregistratie wizard (it gains persistence). The registratie machine already carries + `diplomaHerkomst`, which flows into the submit payload to set `autoApprovable`. +- **Domain**: FE `AanvraagStatus` union + pure `blockActions(status)` (badge + allowed actions); + co-located `*.spec.ts`. +- **UI (atomic, mostly composition)**: new `aanvraag-block`; new "Mijn aanvragen" dashboard section; + `document-chip` preview/download affordance. Reuse existing atoms (`card`, `button`, `alert`, + `heading`, status-icon/badge). All user-facing copy via `$localize`; shared/English components stay + language-agnostic (props with localizable defaults). Stories for each block status + chip preview + (a11y addon on). + +## 9. Delivery phases + +Each phase must leave every gate green (`npm run lint`, `npm test`, `npm run build`, +`cd backend && dotnet test`). Build the **registratie vertical slice first**; herregistratie/intake +blocks are the same pattern copied. + +- **A — Backend Aanvraag store + endpoints + lifecycle** (+ auto-approve-on-read) + tests. +- **B — Backend document bytes + `GET /uploads/{id}/content`** + tests. +- **C — Contracts + adapters** (applications parse boundary; upload content URL). +- **D — `ApplicationsStore` + wizard backend draft-sync + resume-by-link** (retire sessionStorage keys). +- **E — FE `AanvraagStatus` union + `blockActions` + registratie two-flow wiring + herregistratie persistence.** +- **F — Dashboard "Mijn aanvragen" blocks + `document-chip` preview affordance** + stories. + +## 10. Testing / verification + +Unit (co-located): `blockActions`, `AanvraagStatus` transitions, `parseApplication*` boundary; backend +`ApplicationStore` lifecycle + auto-approve window + content endpoint + cancel cascade. UI via Storybook +stories. End-to-end demo: + +1. `docker compose up` (Swagger at `:5000/swagger`) or `npm start` + backend; log in. +2. Start each wizard partway → blocks appear at the top of the dashboard (Concept, "Stap X van Y"). +3. Reopen a Concept → previously uploaded documents **preview/download** (real upload, not `?scenario=upload-*`). +4. **Annuleren** a Concept → block disappears; start a fresh one. +5. Registratie with **DUO** diploma → submit → block shows **In behandeling**, then **Goedgekeurd** + after the processing window (on dashboard reload). +6. Registratie with **handmatig** diploma → submit (not blocked) → block stays **In behandeling** + ("wordt handmatig beoordeeld"). + +## 11. Risks & notes + +- **Retiring sessionStorage** changes existing wizard behavior; ensure resume-by-link and draft-sync + cover the previous "reload keeps progress" guarantee. Bump/remove the old `registratie-v2` / `intake-v3` + keys (no migration — ponytail). +- **Chatty sync**: debounce the per-step `PUT /applications/{id}`; keep it optimistic so typing stays snappy. +- **Dev simulation** uploads have no bytes → hide the preview affordance when the documentId is a + `demo-*` sentinel (or when content 404s). +- **In-memory reset**: backend restart clears all aanvragen/documents — acceptable for the POC; call it + out so testers aren't surprised. + +ponytail: the only genuinely new pieces are the backend `Aanvraag` store, document byte storage, and +the `aanvraag-block` component. Everything else reuses existing store/adapter/``/atom +patterns. Don't build the backoffice, real storage, or withdrawal — YAGNI until asked.