feat(acl): diploma upload stored in the ZGW Documenten API (S-10b, closes #103) (#108)
CI / lint (push) Successful in 1m21s
CI / build (push) Successful in 1m4s
CI / unit (push) Successful in 1m12s
CI / frontend (push) Successful in 2m40s
CI / mutation (push) Successful in 5m31s
CI / verify-stack (push) Successful in 7m56s

## What & why

S-10b: the self-service **diploma upload** is now real. After submitting, the citizen picks a PDF and
uploads it; the portal base64-encodes it client-side → BFF → domain → **ACL**, which stores it in the
ZGW **Documenten (DRC) API** as an `enkelvoudiginformatieobject` and relates it to the zaak, then the
`WachtOpDocumenten` wait completes and the case advances to beoordeling. Per §8.1 only the ACL talks to
ZGW.

Closes #103

Mechanism in **ADR-0018** (proposal #107). Builds on S-10a (#102). The zaak-close-on-expiry item is
carved to **#106 (S-10c)**.

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation (red→green per layer).
- [x] Conventional Commits referencing the issue (`refs #103`).
- [ ] CI green — all Gitea Actions jobs (pending on this PR).
- [x] `docker compose up` health unaffected (ACL boots on a placeholder informatieobjecttype URL; the real one is injected by verify-domain).
- [x] Docs updated (ADR-0018, demo-script, BACKLOG + S-10c).
- [x] ADR added (`docs/architecture/adr-0018-diploma-upload-via-acl-documenten.md`).
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- **ACL** (`OpenZaakGateway.StoreDocumentAsync` + `AclService.StoreDiplomaAsync` + `POST /documenten`) reuses the existing gateway patterns (ZGW Bearer, buffered non-chunked body, **no CRS** — Documenten isn't geo). Unit-tested via the stub handler; an **integration test** stores a real document against live OpenZaak (verify-acl).
- **Transport:** base64 JSON on every hop (portal encodes client-side) — I deviated from proposal #107's multipart to keep one contract shape and avoid `IFormFile`/antiforgery/multipart-client plumbing; fine at diploma size (ADR-0018 §Alternatives).
- **Infra:** `seed_catalogus.py` seeds + publishes a "Diploma" `informatieobjecttype` and relates it to the zaaktype (while both concept); `verify-domain` injects its URL into the ACL. No new ZGW scopes (seed applicatie has `heeft_alle_autorisaties`).
- **e2e:** uploads a real PDF (`setInputFiles`) after the openbaar INGEDIEND row confirms the zaak is open (so storage doesn't race the OpenZaak worker).
- **Scope boundary:** the ZGW zaak is not set to a cancellation status on 30-day expiry — that's #106 (S-10c).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #108
This commit was merged in pull request #108.
This commit is contained in:
not
2026-07-21 12:15:33 +00:00
parent 4777ff2b1d
commit 0904df8db0
36 changed files with 823 additions and 76 deletions
@@ -2,10 +2,12 @@ using Big.Domain;
namespace Big.Application;
/// <summary>A zorgprofessional's signal that they have supplied the documents their registration is
/// waiting for ("documenten aanleveren"). <paramref name="Bsn"/> is the authenticated caller (from the
/// DigiD token, forwarded by the BFF): only the registration's own bsn may provide its documents.</summary>
public sealed record ProvideDocumentsCommand(RegistrationId RegistrationId, string Bsn);
/// <summary>A zorgprofessional's upload of the diploma their registration is waiting for ("documenten
/// aanleveren"). <paramref name="Bsn"/> is the authenticated caller (from the DigiD token, forwarded by
/// the BFF): only the registration's own bsn may provide its documents. <paramref name="Content"/> is
/// the raw file, with its <paramref name="FileName"/> and <paramref name="ContentType"/>.</summary>
public sealed record ProvideDocumentsCommand(
RegistrationId RegistrationId, string Bsn, byte[] Content, string FileName, string ContentType);
/// <summary>The outcome of a provide-documents request.</summary>
public enum ProvideDocumentsOutcome
@@ -19,14 +21,14 @@ public enum ProvideDocumentsOutcome
}
/// <summary>
/// The provide-documents use case (S-10a): a zorgprofessional supplies the documents their registration
/// is parked waiting for, completing the WachtOpDocumenten task so the registratie process leaves the
/// 30-day wait and continues to beoordeling (ADR-0017). Owner-scoped by bsn. Completing the wait is
/// best-effort: if the registration never started a process (or already left the wait), the request
/// still stands, mirroring how <see cref="WithdrawRegistration"/> cancels best-effort. The actual file
/// upload and its ZGW storage via the ACL is S-10b; this is the trigger that unblocks the process.
/// The provide-documents use case (S-10a/S-10b): a zorgprofessional uploads the diploma their
/// registration is parked waiting for. The document is stored in ZGW via the ACL (§8.1), then the
/// WachtOpDocumenten task is completed so the registratie process leaves the 30-day wait and continues
/// to beoordeling (ADR-0017). Owner-scoped by bsn. Both steps are best-effort about missing preconditions
/// (mirroring <see cref="WithdrawRegistration"/>): storage needs an opened zaak, and completion needs a
/// running process — a request that arrives before either still stands, storing/completing what it can.
/// </summary>
public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow)
public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow, IAclClient acl)
{
public async Task<ProvideDocumentsOutcome> HandleAsync(ProvideDocumentsCommand command, CancellationToken ct = default)
{
@@ -38,6 +40,11 @@ public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient w
if (registration is null || registration.Bsn != command.Bsn)
return ProvideDocumentsOutcome.NotFound;
// Store the diploma against the zaak (once it is opened) — the ACL is the only ZGW caller (§8.1).
if (registration.ZaakUrl is not null)
await acl.StoreDiplomaAsync(
registration.ZaakUrl, command.Content, command.FileName, command.ContentType, ct);
// Complete the document wait (if a process is running) so beoordeling can proceed.
if (registration.ProcessInstanceId is not null)
await workflow.CompleteDocumentWaitAsync(registration.ProcessInstanceId, ct);