From 3c344caa2986ef438ebd14a321149d98b9a695bd Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 20 Jul 2026 12:05:40 +0200 Subject: [PATCH] feat(domain): ProvideDocuments stores the diploma via the ACL then completes the wait (refs #103) IAclClient.StoreDiplomaAsync + AclHttpClient (base64 JSON to the ACL /documenten endpoint). ProvideDocuments stores the uploaded bytes against the zaak (when opened) before completing WachtOpDocumenten; the domain endpoint accepts the file base64-encoded. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Api/Program.cs | 13 +++++++-- services/domain/Big.Application/Ports.cs | 7 +++++ .../Big.Application/ProvideDocuments.cs | 29 ++++++++++++------- .../Big.Infrastructure/AclHttpClient.cs | 26 +++++++++++++++++ tests/acceptance/Steps/EenZaakOpenenSteps.cs | 1 + .../acceptance/Support/InMemoryDomainPorts.cs | 8 +++++ .../acceptance/Support/InMemoryZaakGateway.cs | 3 ++ 7 files changed, 74 insertions(+), 13 deletions(-) diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index c78e79b..56d6791 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -120,8 +120,17 @@ app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsR if (string.IsNullOrWhiteSpace(body?.Bsn)) return Results.BadRequest(new { error = "A bsn is required to provide documents." }); + if (string.IsNullOrWhiteSpace(body.ContentBase64)) + return Results.BadRequest(new { error = "A document is required." }); - var outcome = await provide.HandleAsync(new ProvideDocumentsCommand(new RegistrationId(guid), body.Bsn), ct); + byte[] content; + try { content = Convert.FromBase64String(body.ContentBase64); } + catch (FormatException) { return Results.BadRequest(new { error = "The document content is not valid base64." }); } + + var command = new ProvideDocumentsCommand( + new RegistrationId(guid), body.Bsn, content, + body.FileName ?? "diploma.pdf", body.ContentType ?? "application/pdf"); + var outcome = await provide.HandleAsync(command, ct); return outcome == ProvideDocumentsOutcome.Accepted ? Results.NoContent() : Results.NotFound(); }); @@ -152,7 +161,7 @@ public sealed record DecideRequest(string Besluit); public sealed record WithdrawRequest(string Bsn); -public sealed record ProvideDocumentsRequest(string Bsn); +public sealed record ProvideDocumentsRequest(string Bsn, string ContentBase64, string? FileName = null, string? ContentType = null); public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl); diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index af12888..9d2c5e7 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -52,6 +52,13 @@ public interface IAclClient /// the zaak's final status — which OpenZaak notifies over NRC; the domain never names statustypen. /// Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default); + + /// + /// Store an uploaded diploma against the zaak (S-10b). The domain hands over the zaak, the raw file + /// bytes, and the file's name/type; the ACL creates the ZGW informatieobject and relates it to the + /// zaak (§8.1). Returns the stored document's URL. + /// + Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default); } /// diff --git a/services/domain/Big.Application/ProvideDocuments.cs b/services/domain/Big.Application/ProvideDocuments.cs index c5cdcce..59d997f 100644 --- a/services/domain/Big.Application/ProvideDocuments.cs +++ b/services/domain/Big.Application/ProvideDocuments.cs @@ -2,10 +2,12 @@ using Big.Domain; namespace Big.Application; -/// A zorgprofessional's signal that they have supplied the documents their registration is -/// waiting for ("documenten aanleveren"). is the authenticated caller (from the -/// DigiD token, forwarded by the BFF): only the registration's own bsn may provide its documents. -public sealed record ProvideDocumentsCommand(RegistrationId RegistrationId, string Bsn); +/// A zorgprofessional's upload of the diploma their registration is waiting for ("documenten +/// aanleveren"). is the authenticated caller (from the DigiD token, forwarded by +/// the BFF): only the registration's own bsn may provide its documents. is +/// the raw file, with its and . +public sealed record ProvideDocumentsCommand( + RegistrationId RegistrationId, string Bsn, byte[] Content, string FileName, string ContentType); /// The outcome of a provide-documents request. public enum ProvideDocumentsOutcome @@ -19,14 +21,14 @@ public enum ProvideDocumentsOutcome } /// -/// 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 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 ): storage needs an opened zaak, and completion needs a +/// running process — a request that arrives before either still stands, storing/completing what it can. /// -public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow) +public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow, IAclClient acl) { public async Task 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); diff --git a/services/domain/Big.Infrastructure/AclHttpClient.cs b/services/domain/Big.Infrastructure/AclHttpClient.cs index 217f9dc..22c235c 100644 --- a/services/domain/Big.Infrastructure/AclHttpClient.cs +++ b/services/domain/Big.Infrastructure/AclHttpClient.cs @@ -31,6 +31,23 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli response.EnsureSuccessStatusCode(); } + public async Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + ArgumentNullException.ThrowIfNull(content); + + // The file crosses this boundary base64-encoded in JSON — the domain and ACL contracts are + // JSON, and a diploma is small (S-10b, ADR). The ACL turns it into a ZGW informatieobject. + using var response = await http.PostAsJsonAsync( + new Uri(options.BaseUrl, "documenten"), + new StoreDocumentRequest(zaakUrl.ToString(), Convert.ToBase64String(content), fileName, contentType), ct); + response.EnsureSuccessStatusCode(); + + var stored = await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("The ACL returned an empty document response."); + return new Uri(stored.InformatieobjectUrl); + } + private sealed record OpenZaakRequest( [property: JsonPropertyName("bsn")] string Bsn, [property: JsonPropertyName("reference")] string Reference); @@ -38,4 +55,13 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl); private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl); + + private sealed record StoreDocumentRequest( + [property: JsonPropertyName("zaakUrl")] string ZaakUrl, + [property: JsonPropertyName("contentBase64")] string ContentBase64, + [property: JsonPropertyName("fileName")] string FileName, + [property: JsonPropertyName("contentType")] string ContentType); + + private sealed record StoreDocumentResponse( + [property: JsonPropertyName("informatieobjectUrl")] string InformatieobjectUrl); } diff --git a/tests/acceptance/Steps/EenZaakOpenenSteps.cs b/tests/acceptance/Steps/EenZaakOpenenSteps.cs index 04780e1..6dd2fc4 100644 --- a/tests/acceptance/Steps/EenZaakOpenenSteps.cs +++ b/tests/acceptance/Steps/EenZaakOpenenSteps.cs @@ -30,6 +30,7 @@ public sealed class EenZaakOpenenSteps VerantwoordelijkeOrganisatie = values["verantwoordelijkeOrganisatie"], Vertrouwelijkheidaanduiding = values["vertrouwelijkheidaanduiding"], ZaaktypeUrl = new Uri(values["zaaktype"]), + InformatieobjecttypeUrl = new Uri("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"), }; } diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index f480320..c63b05e 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -59,6 +59,14 @@ public sealed class InMemoryAclClient : IAclClient ApprovedZaakUrl = zaakUrl; return Task.CompletedTask; } + + public (Uri ZaakUrl, string FileName)? StoredDiploma { get; private set; } + + public Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default) + { + StoredDiploma = (zaakUrl, fileName); + return Task.FromResult(new Uri("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/acc-doc")); + } } /// An in-memory user-task client for the beoordeling acceptance scenario: it holds one open diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs index 09c6414..e9e205d 100644 --- a/tests/acceptance/Support/InMemoryZaakGateway.cs +++ b/tests/acceptance/Support/InMemoryZaakGateway.cs @@ -27,4 +27,7 @@ public sealed class InMemoryZaakGateway : IZaakGateway public Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) => Task.FromResult("ACC-REF-1"); + + public Task StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default) + => Task.FromResult(new Uri("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/acc-doc")); }