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()
{