Mijn aanvragen (A): backend Aanvraag store + lifecycle endpoints
Adds the backend-owned Aanvraag aggregate (PRD 0001, phase A) — the system of
record the dashboard will read. In-memory static store mirroring DocumentStore.
- ApplicationStore: create/get/list/draft-sync/cancel/submit; status COMPUTED ON
READ (Mappers.ToStatusDto(now)) so auto-approval is pure timestamp arithmetic,
no timers/jobs (ProcessingWindow = 8s).
- Endpoints: GET /applications, GET/POST/PUT/DELETE /applications/{id},
POST /applications/{id}/submit.
- Lifecycle: registratie duo -> auto (Goedgekeurd after window), handmatig ->
manual pending (no 422); herregistratie/intake 0 uren -> Afgewezen else auto.
Cancel blocks submitted aanvragen (409, no withdrawal in scope).
- Old /registrations endpoint + RejectRegistratie 422 left intact (retire in E).
- ApplicationTests: lifecycle + auto-approve window boundary (pure). 54/54 green.
Also checks in the PRD (docs/prd/0001).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -71,3 +71,39 @@ public sealed record HerregistratieRequest(int Uren, IReadOnlyList<DocumentRefDt
|
||||
public sealed record ChangeRequestRequest(string Straat, string Postcode, string Woonplaats);
|
||||
|
||||
public sealed record ReferentieResponse(string Referentie);
|
||||
|
||||
// --- Applications (aanvragen): the system of record for the dashboard. ---
|
||||
|
||||
// Status is a discriminated union by Tag (Concept | InBehandeling | Goedgekeurd |
|
||||
// Afgewezen); only the fields relevant to a tag are populated. Mirrors the FE union.
|
||||
public sealed record AanvraagStatusDto(
|
||||
string Tag,
|
||||
int? StepIndex = null,
|
||||
int? StepCount = null,
|
||||
string? Referentie = null,
|
||||
bool? Manual = null,
|
||||
string? Reden = null);
|
||||
|
||||
public sealed record ApplicationSummaryDto(
|
||||
string Id, string Type, AanvraagStatusDto Status,
|
||||
IReadOnlyList<string> DocumentIds,
|
||||
string CreatedAt, string UpdatedAt, string? SubmittedAt);
|
||||
|
||||
public sealed record ApplicationDetailDto(
|
||||
string Id, string Type, AanvraagStatusDto Status,
|
||||
System.Text.Json.JsonElement? Draft,
|
||||
IReadOnlyList<string> 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<string>? 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<DocumentRefDto>? Documents = null);
|
||||
|
||||
public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
110
backend/src/BigRegister.Api/Data/ApplicationStore.cs
Normal file
110
backend/src/BigRegister.Api/Data/ApplicationStore.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using System.Text.Json;
|
||||
using BigRegister.Domain.Submissions;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> 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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory application store (no DB), mirrors <see cref="DocumentStore"/>.
|
||||
/// ponytail: one global lock — fine for a single-process demo; swap for per-key
|
||||
/// locks if it ever serves load.
|
||||
/// </summary>
|
||||
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<string, Aanvraag> _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<Aanvraag> 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<string>? 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<string> 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<string>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ApplicationDetailDto>()
|
||||
.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<ApplicationDetailDto>(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<SubmitApplicationResponse>()
|
||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
app.Run();
|
||||
|
||||
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
|
||||
|
||||
136
backend/tests/BigRegister.Tests/ApplicationTests.cs
Normal file
136
backend/tests/BigRegister.Tests/ApplicationTests.cs
Normal file
@@ -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<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private async Task<ApplicationDetailDto> 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<ApplicationDetailDto>())!;
|
||||
}
|
||||
|
||||
private Task<List<ApplicationSummaryDto>?> List() =>
|
||||
_client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/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<ApplicationDetailDto>($"/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<SubmitApplicationResponse>())!;
|
||||
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<SubmitApplicationResponse>())!;
|
||||
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<SubmitApplicationResponse>())!;
|
||||
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);
|
||||
}
|
||||
}
|
||||
219
docs/prd/0001-mijn-aanvragen-en-wizardstatus.md
Normal file
219
docs/prd/0001-mijn-aanvragen-en-wizardstatus.md
Normal file
@@ -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 `<app-async>` 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=<id>`; 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<ApplicationSummaryDto>` 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 `<app-async>`; 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=<id>` (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/`<app-async>`/atom
|
||||
patterns. Don't build the backoffice, real storage, or withdrawal — YAGNI until asked.
|
||||
Reference in New Issue
Block a user