diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs index f220cb1..9581455 100644 --- a/services/acl/Acl.Api/Program.cs +++ b/services/acl/Acl.Api/Program.cs @@ -40,6 +40,15 @@ app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl return Results.Ok(new { reference }); }); +// Store an uploaded diploma against a zaak (S-10b): the domain sends the file as base64; the ACL +// creates the ZGW enkelvoudiginformatieobject and relates it to the zaak (§8.1). Returns its URL. +app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, CancellationToken ct) => +{ + var url = await acl.StoreDiplomaAsync( + new Uri(body.ZaakUrl), Convert.FromBase64String(body.ContentBase64), body.FileName, body.ContentType, ct); + return Results.Ok(new { informatieobjectUrl = url.ToString() }); +}); + app.Run(); public sealed record OpenZaakRequest(string Bsn, string Reference); @@ -48,4 +57,6 @@ public sealed record SetStatusRequest(string ZaakUrl); public sealed record ZaakReferenceRequest(string ZaakUrl); +public sealed record StoreDocumentRequest(string ZaakUrl, string ContentBase64, string FileName, string ContentType); + public partial class Program; diff --git a/services/acl/Acl.Application/AclDefaults.cs b/services/acl/Acl.Application/AclDefaults.cs index 232d3a4..d378c8c 100644 --- a/services/acl/Acl.Application/AclDefaults.cs +++ b/services/acl/Acl.Application/AclDefaults.cs @@ -7,4 +7,8 @@ public sealed class AclDefaults public required string VerantwoordelijkeOrganisatie { get; init; } public required string Vertrouwelijkheidaanduiding { get; init; } public required Uri ZaaktypeUrl { get; init; } + + /// The informatieobjecttype an uploaded diploma is filed under (S-10b). Seeded in the + /// catalogus and injected like . + public required Uri InformatieobjecttypeUrl { get; init; } } diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index a70e7ca..f692041 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -37,4 +37,33 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc return gateway.GetZaakIdentificatieAsync(zaakUrl, ct); } + + /// + /// Store an uploaded diploma against the zaak (S-10b): default-fill the ZGW-mandatory document + /// fields (informatieobjecttype, bronorganisatie, vertrouwelijkheidaanduiding, taal, creatiedatum) + /// and hand the file to the gateway, which creates the informatieobject and relates it to the zaak. + /// The domain supplies only the zaak, the bytes, and the file's name/type (§8.1). + /// + public Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + ArgumentNullException.ThrowIfNull(content); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + ArgumentException.ThrowIfNullOrWhiteSpace(contentType); + + var request = new DocumentRequest( + defaults.Bronorganisatie, + defaults.InformatieobjecttypeUrl, + defaults.Vertrouwelijkheidaanduiding, + zaakUrl, + clock.Today, + Titel: "Diploma", + Auteur: "zorgprofessional", + Taal: "nld", + Bestandsnaam: fileName, + Formaat: contentType, + Inhoud: content); + + return gateway.StoreDocumentAsync(request, ct); + } } diff --git a/services/acl/Acl.Application/DocumentRequest.cs b/services/acl/Acl.Application/DocumentRequest.cs new file mode 100644 index 0000000..885148f --- /dev/null +++ b/services/acl/Acl.Application/DocumentRequest.cs @@ -0,0 +1,17 @@ +namespace Acl.Application; + +/// The fully default-filled diploma document the gateway will create in the ZGW Documenten +/// API and relate to the zaak (S-10b). is the raw file content; the gateway +/// base64-encodes it into the ZGW inhoud field. +public sealed record DocumentRequest( + string Bronorganisatie, + Uri Informatieobjecttype, + string Vertrouwelijkheidaanduiding, + Uri Zaak, + DateOnly Creatiedatum, + string Titel, + string Auteur, + string Taal, + string Bestandsnaam, + string Formaat, + byte[] Inhoud); diff --git a/services/acl/Acl.Application/IZaakGateway.cs b/services/acl/Acl.Application/IZaakGateway.cs index 583baf5..73903f0 100644 --- a/services/acl/Acl.Application/IZaakGateway.cs +++ b/services/acl/Acl.Application/IZaakGateway.cs @@ -16,4 +16,11 @@ public interface IZaakGateway /// Read the zaak's identificatie — the public-safe reference the register shows. /// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78). Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default); + + /// + /// Store a diploma document (S-10b): create an enkelvoudiginformatieobject in the ZGW + /// Documenten API and relate it to the zaak via a zaakinformatieobject. Returns the URL of + /// the created informatieobject. + /// + Task StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default); } diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs index 92d85ea..cee0c9a 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -80,6 +80,36 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : return zaak.Identificatie; } + public async Task StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + + // 1. Create the enkelvoudiginformatieobject in the Documenten API (not a geo API — no CRS). + var created = await PostForUrlAsync( + "/documenten/api/v1/enkelvoudiginformatieobjecten", + new EnkelvoudigInformatieobjectDto( + request.Bronorganisatie, + request.Creatiedatum.ToString("yyyy-MM-dd"), + request.Titel, + request.Auteur, + request.Taal, + request.Informatieobjecttype.ToString(), + Convert.ToBase64String(request.Inhoud), + request.Bestandsnaam, + request.Inhoud.Length, + request.Vertrouwelijkheidaanduiding, + request.Formaat, + "definitief"), + "Creating the informatieobject", ct); + + // 2. Relate it to the zaak (Zaken API — no CRS). + await PostAsync("/zaken/api/v1/zaakinformatieobjecten", + new ZaakInformatieobjectDto(request.Zaak.ToString(), created.ToString()), + "Relating the informatieobject to the zaak", ct); + + return created; + } + // POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets // a Content-Length instead of a chunked body (as with zaak-create). private async Task PostAsync(string path, object dto, string action, CancellationToken ct) @@ -96,6 +126,26 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : await EnsureSuccessAsync(response, action, ct); } + // POSTs a non-geo ZGW resource and returns the created resource's URL (as PostAsync, but reads back + // the `url` of the created object). Buffers the body so uwsgi gets a Content-Length. + private async Task PostForUrlAsync(string path, object dto, string action, CancellationToken ct) + { + using var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path)) + { + Content = JsonContent.Create(dto), + }; + message.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); + await message.Content.LoadIntoBufferAsync(ct); + + using var response = await http.SendAsync(message, ct); + await EnsureSuccessAsync(response, action, ct); + + var created = await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException($"OpenZaak returned an empty response for {action}"); + return new Uri(created.Url); + } + // EnsureSuccessStatusCode discards the response body; ZGW returns a JSON problem detail on 400 that // is essential for diagnosing a rejected request, so surface it in the exception. private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct) @@ -182,4 +232,25 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : private sealed record ResultaattypeDto( [property: JsonPropertyName("url")] string Url); + + private sealed record CreatedDto( + [property: JsonPropertyName("url")] string Url); + + private sealed record EnkelvoudigInformatieobjectDto( + [property: JsonPropertyName("bronorganisatie")] string Bronorganisatie, + [property: JsonPropertyName("creatiedatum")] string Creatiedatum, + [property: JsonPropertyName("titel")] string Titel, + [property: JsonPropertyName("auteur")] string Auteur, + [property: JsonPropertyName("taal")] string Taal, + [property: JsonPropertyName("informatieobjecttype")] string Informatieobjecttype, + [property: JsonPropertyName("inhoud")] string Inhoud, + [property: JsonPropertyName("bestandsnaam")] string Bestandsnaam, + [property: JsonPropertyName("bestandsomvang")] int Bestandsomvang, + [property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding, + [property: JsonPropertyName("formaat")] string Formaat, + [property: JsonPropertyName("status")] string Status); + + private sealed record ZaakInformatieobjectDto( + [property: JsonPropertyName("zaak")] string Zaak, + [property: JsonPropertyName("informatieobject")] string Informatieobject); } diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index 2e74c8f..fe2a81a 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -30,6 +30,15 @@ public class AclServiceTests ReadReferenceFor = zaakUrl; return Task.FromResult("REG-FROM-ZAAK"); } + + public DocumentRequest? StoredDocument; + public Uri DocumentResult { get; } = new("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/doc-1"); + + public Task StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default) + { + StoredDocument = request; + return Task.FromResult(DocumentResult); + } } private static AclDefaults Defaults() => new() @@ -38,6 +47,7 @@ public class AclServiceTests VerantwoordelijkeOrganisatie = "517439943", Vertrouwelijkheidaanduiding = "openbaar", ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), + InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"), }; private sealed class FixedClock(DateOnly today) : IClock @@ -55,6 +65,7 @@ public class AclServiceTests VerantwoordelijkeOrganisatie = "517439943", Vertrouwelijkheidaanduiding = "openbaar", ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), + InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"), }; var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); @@ -81,6 +92,7 @@ public class AclServiceTests VerantwoordelijkeOrganisatie = "517439943", Vertrouwelijkheidaanduiding = "openbaar", ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), + InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"), }; var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); @@ -114,6 +126,41 @@ public class AclServiceTests Assert.Null(gateway.Approved); } + [Fact] + public async Task Storing_a_diploma_default_fills_the_document_fields_and_returns_its_url() + { + var gateway = new FakeGateway(); + var defaults = Defaults(); + var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); + var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); + + var url = await service.StoreDiplomaAsync(zaak, [1, 2, 3], "diploma.pdf", "application/pdf"); + + Assert.Equal(gateway.DocumentResult, url); + var req = gateway.StoredDocument!; + Assert.Equal(zaak, req.Zaak); + Assert.Equal(defaults.InformatieobjecttypeUrl, req.Informatieobjecttype); + Assert.Equal("517439943", req.Bronorganisatie); + Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); + Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum); + Assert.Equal("nld", req.Taal); + Assert.Equal("diploma.pdf", req.Bestandsnaam); + Assert.Equal("application/pdf", req.Formaat); + Assert.Equal(new byte[] { 1, 2, 3 }, req.Inhoud); + } + + [Fact] + public async Task Storing_a_diploma_rejects_null_or_blank_arguments() + { + var service = new AclService(new FakeGateway(), Defaults(), new FixedClock(new DateOnly(2026, 6, 4))); + var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); + + await Assert.ThrowsAsync(() => service.StoreDiplomaAsync(null!, [1], "d.pdf", "application/pdf")); + await Assert.ThrowsAsync(() => service.StoreDiplomaAsync(zaak, null!, "d.pdf", "application/pdf")); + await Assert.ThrowsAnyAsync(() => service.StoreDiplomaAsync(zaak, [1], " ", "application/pdf")); + await Assert.ThrowsAnyAsync(() => service.StoreDiplomaAsync(zaak, [1], "d.pdf", " ")); + } + [Fact] public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie() {