From 5807937229e6f318d6466df4e3462fab8dac8bb8 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 29 Jul 2026 20:54:31 +0200 Subject: [PATCH] feat(zgw): OpenZaak Documenten (DRC) upload + zaak link (WP-51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the OpenZaak seam with IDocumentSource, sibling of IZaakSource (WP-49/50): an upload always lands locally first (DocumentStore stays the record of truth for preview/download/audit) and, when Zgw:Enabled=true, is also registered as a DRC enkelvoudiginformatie- object; once a zaak exists (IZaakSource.CreateZaak now also returns its ZaakUrl), submit links each document to it via zaakinformatie- object. FE upload/list DTOs are unchanged. - ZgwOptions gains DrcBaseUrl + a category->informatieobjecttype URL map (the document analogue of ZaaktypeUrls). - LocalDocumentSource is the same DocumentStore.Add/Link calls the endpoints used to make inline — zero behaviour change offline. - OpenZaakDocumentSource POSTs the eio then the zaak link, persisting the DRC url (DocumentStore.SetDrcUrl) so linking doesn't re-upload. - Factored the GET/POST-with-bearer-JWT plumbing shared with OpenZaakZaakSource into ZgwHttpClient; shared the stub handler between the two source test classes as ZgwStubHandler. Co-Authored-By: Claude Sonnet 5 --- .../BigRegister.Api/Data/ApplicationStore.cs | 21 ++ .../src/BigRegister.Api/Data/DocumentStore.cs | 19 ++ .../BigRegister.Api/Data/IDocumentSource.cs | 28 ++ .../src/BigRegister.Api/Data/IZaakSource.cs | 14 +- .../Data/LocalDocumentSource.cs | 23 ++ .../BigRegister.Api/Data/LocalZaakSource.cs | 4 +- .../20260729071227_ZaakAndDrcUrls.Designer.cs | 279 ++++++++++++++++++ .../20260729071227_ZaakAndDrcUrls.cs | 38 +++ .../Migrations/AppDbContextModelSnapshot.cs | 6 + backend/src/BigRegister.Api/Program.cs | 27 +- .../Zgw/OpenZaakDocumentSource.cs | 103 +++++++ .../BigRegister.Api/Zgw/OpenZaakZaakSource.cs | 46 +-- .../src/BigRegister.Api/Zgw/ZgwHttpClient.cs | 39 +++ backend/src/BigRegister.Api/Zgw/ZgwOptions.cs | 15 +- .../OpenZaakDocumentSourceTests.cs | 108 +++++++ .../OpenZaakZaakSourceTests.cs | 38 +-- .../tests/BigRegister.Tests/ZgwStubHandler.cs | 32 ++ docs/reference/openzaak-integration.md | 112 ++++--- 18 files changed, 836 insertions(+), 116 deletions(-) create mode 100644 backend/src/BigRegister.Api/Data/IDocumentSource.cs create mode 100644 backend/src/BigRegister.Api/Data/LocalDocumentSource.cs create mode 100644 backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.Designer.cs create mode 100644 backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.cs create mode 100644 backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs create mode 100644 backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs create mode 100644 backend/tests/BigRegister.Tests/OpenZaakDocumentSourceTests.cs create mode 100644 backend/tests/BigRegister.Tests/ZgwStubHandler.cs diff --git a/backend/src/BigRegister.Api/Data/ApplicationStore.cs b/backend/src/BigRegister.Api/Data/ApplicationStore.cs index 9b84824..3eb3829 100644 --- a/backend/src/BigRegister.Api/Data/ApplicationStore.cs +++ b/backend/src/BigRegister.Api/Data/ApplicationStore.cs @@ -26,6 +26,13 @@ public sealed class Aanvraag public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset? SubmittedAt { get; set; } + + /// The OpenZaak zaak's URL, set once CreateZaak (WP-50) registers one — null under + /// the local source. Persisted so later steps (WP-51's document→zaak link) can find it + /// without a network round-trip; IZaakSource.CreateZaak itself doesn't write here (the + /// endpoint does, via ) to keep the seam's write + /// surface at "return data", not "reach into another store". + public string? ZaakUrl { get; set; } } /// @@ -172,4 +179,18 @@ public static class ApplicationStore return a; } } + + /// Persist the zaak URL CreateZaak (WP-50) registered for this aanvraag. No-op if + /// the aanvraag is gone (shouldn't happen — this runs right after Submit found it). + public static void SetZaakUrl(string id, string zaakUrl) + { + lock (_gate) + { + using var db = Db.Create(); + var a = db.Applications.Find(id); + if (a is null) return; + a.ZaakUrl = zaakUrl; + db.SaveChanges(); + } + } } diff --git a/backend/src/BigRegister.Api/Data/DocumentStore.cs b/backend/src/BigRegister.Api/Data/DocumentStore.cs index 8814f42..b190cd1 100644 --- a/backend/src/BigRegister.Api/Data/DocumentStore.cs +++ b/backend/src/BigRegister.Api/Data/DocumentStore.cs @@ -11,6 +11,12 @@ public sealed record StoredDocument( string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt) { public bool Linked { get; set; } + + /// The OpenZaak DRC enkelvoudiginformatieobject's URL, set once Upload (WP-51) + /// registers one — null under the local source. Persisted so the later zaak-link step can + /// find it without re-uploading; not part of the positional constructor, same reasoning as + /// (every existing `new StoredDocument(...)` call site keeps working). + public string? DrcUrl { get; set; } } /// Id is EF Core's auto-increment key — not part of the positional @@ -69,6 +75,19 @@ public static class DocumentStore } } + /// Persist the DRC url an OpenZaak upload (WP-51) registered for a document. + public static void SetDrcUrl(string documentId, string drcUrl) + { + lock (_gate) + { + using var db = Db.Create(); + var d = db.Documents.Find(documentId); + if (d is null) return; + d.DrcUrl = drcUrl; + db.SaveChanges(); + } + } + /// Mark digital documents as linked to a finalised submission (blocks user delete). public static void Link(IEnumerable documentIds) { diff --git a/backend/src/BigRegister.Api/Data/IDocumentSource.cs b/backend/src/BigRegister.Api/Data/IDocumentSource.cs new file mode 100644 index 0000000..b19268a --- /dev/null +++ b/backend/src/BigRegister.Api/Data/IDocumentSource.cs @@ -0,0 +1,28 @@ +using BigRegister.Api.Contracts; + +namespace BigRegister.Api.Data; + +/// +/// The documents seam (WP-51), sibling of : uploads always land +/// locally first ( stays the record of truth for preview/download/ +/// audit regardless of config, exactly like ApplicationStore.Submit for aanvragen, +/// WP-50) — this interface is only the OpenZaak integration side-effect, selected the same way +/// (Zgw:Enabled). Default binding is (offline); +/// OpenZaakDocumentSource also registers each upload as a DRC +/// enkelvoudiginformatieobject and links it to a zaak once one exists. +/// +public interface IDocumentSource +{ + /// Store an uploaded file (already validated by DocumentRules) and return the + /// existing DTO unchanged, whichever source is active. + UploadResponse Upload( + string localId, string categoryId, string wizardId, string fileName, string contentType, + byte[] content, string owner); + + /// Finalise a set of already-uploaded documents against a just-submitted aanvraag + /// (WP-50/51): local behaviour is exactly today's DocumentStore.Link; the OpenZaak + /// source additionally links each document (that has a DRC url) to the zaak, once + /// is known (null under the local , in + /// which case there is nothing extra to link). + void LinkToZaak(IReadOnlyList documentIds, string? zaakUrl); +} diff --git a/backend/src/BigRegister.Api/Data/IZaakSource.cs b/backend/src/BigRegister.Api/Data/IZaakSource.cs index 9c29b0d..291a8db 100644 --- a/backend/src/BigRegister.Api/Data/IZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/IZaakSource.cs @@ -22,11 +22,13 @@ public interface IZaakSource /// /// Register a just-submitted as a zaak (WP-50). The aanvraag is /// already persisted locally (ApplicationStore.Submit already ran) — this is the - /// integration side-effect, and its return value is what the submit endpoint hands back to - /// the FE (ADR-0001: route the create through the existing submit response DTO, don't add a - /// second one). The local source is a pure passthrough of the already-computed local - /// reference/status; the OpenZaak source creates a Zaak (+ status + rol) and maps the result - /// back into the same shape. + /// integration side-effect, and (Referentie, Status) is what the submit endpoint hands back + /// to the FE (ADR-0001: route the create through the existing submit response DTO, don't add + /// a second one). The local source is a pure passthrough of the already-computed local + /// reference/status (ZaakUrl null — nothing to persist); the OpenZaak source creates a Zaak + /// (+ status + rol) and maps the result back into the same shape, returning the zaak's URL + /// so the endpoint can persist it (, WP-51 needs it + /// to later link documents to this zaak). /// - (string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now); + (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now); } diff --git a/backend/src/BigRegister.Api/Data/LocalDocumentSource.cs b/backend/src/BigRegister.Api/Data/LocalDocumentSource.cs new file mode 100644 index 0000000..8356aae --- /dev/null +++ b/backend/src/BigRegister.Api/Data/LocalDocumentSource.cs @@ -0,0 +1,23 @@ +using BigRegister.Api.Contracts; + +namespace BigRegister.Api.Data; + +/// +/// The default — uploads go only to the local SQLite +/// , exactly as before this seam existed (WP-51). Zero behaviour +/// change: this is the same DocumentStore.Add/DocumentStore.Link the upload/ +/// submit endpoints used to call inline. +/// +public sealed class LocalDocumentSource : IDocumentSource +{ + public UploadResponse Upload( + string localId, string categoryId, string wizardId, string fileName, string contentType, + byte[] content, string owner) + { + var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, owner); + return new UploadResponse(doc.DocumentId, doc.LocalId); + } + + public void LinkToZaak(IReadOnlyList documentIds, string? zaakUrl) => + DocumentStore.Link(documentIds); +} diff --git a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs index 7dad750..c58b7b6 100644 --- a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs @@ -15,6 +15,6 @@ public sealed class LocalZaakSource : IZaakSource /// No external zaak to create — the aanvraag's local submit already IS the record /// of truth, exactly as before this seam existed (WP-50). Zero behaviour change. - public (string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) => - (aanvraag.Referentie!, aanvraag.ToStatusDto(now)); + public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) => + (aanvraag.Referentie!, aanvraag.ToStatusDto(now), null); } diff --git a/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.Designer.cs b/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.Designer.cs new file mode 100644 index 0000000..0a8939a --- /dev/null +++ b/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.Designer.cs @@ -0,0 +1,279 @@ +// +using System; +using BigRegister.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BigRegister.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260729071227_ZaakAndDrcUrls")] + partial class ZaakAndDrcUrls + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AutoApprovable") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DocumentIds") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Draft") + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Reden") + .HasColumnType("TEXT"); + + b.Property("Referentie") + .HasColumnType("TEXT"); + + b.Property("StepCount") + .HasColumnType("INTEGER"); + + b.Property("StepIndex") + .HasColumnType("INTEGER"); + + b.Property("Submitted") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ZaakUrl") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Applications"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Actor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocumentId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.AuthzAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Resource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AuthzAudit"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b => + { + b.Property("BriefId") + .HasColumnType("TEXT"); + + b.Property("ArchivedHtml") + .HasColumnType("TEXT"); + + b.Property("Beroep") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DrafterId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Placeholders") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sections") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SentOrgTemplateVersion") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubOrgId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("BriefId"); + + b.HasIndex("Owner") + .IsUnique(); + + b.ToTable("Briefs"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.FeatureFlagEntity", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.HasKey("Key"); + + b.ToTable("FeatureFlags"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b => + { + b.Property("SubOrgId") + .HasColumnType("TEXT"); + + b.Property("Draft") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("History") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("SubOrgId"); + + b.ToTable("OrgTemplates"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b => + { + b.Property("DocumentId") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DrcUrl") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Linked") + .HasColumnType("INTEGER"); + + b.Property("LocalId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("WizardId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("DocumentId"); + + b.ToTable("Documents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.cs b/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.cs new file mode 100644 index 0000000..321b3a6 --- /dev/null +++ b/backend/src/BigRegister.Api/Data/Migrations/20260729071227_ZaakAndDrcUrls.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BigRegister.Api.Data.Migrations +{ + /// + public partial class ZaakAndDrcUrls : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DrcUrl", + table: "Documents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "ZaakUrl", + table: "Applications", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DrcUrl", + table: "Documents"); + + migrationBuilder.DropColumn( + name: "ZaakUrl", + table: "Applications"); + } + } +} diff --git a/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs index 358b68a..1ff72c5 100644 --- a/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs @@ -64,6 +64,9 @@ namespace BigRegister.Api.Data.Migrations b.Property("UpdatedAt") .HasColumnType("TEXT"); + b.Property("ZaakUrl") + .HasColumnType("TEXT"); + b.HasKey("Id"); b.ToTable("Applications"); @@ -235,6 +238,9 @@ namespace BigRegister.Api.Data.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("DrcUrl") + .HasColumnType("TEXT"); + b.Property("FileName") .IsRequired() .HasColumnType("TEXT"); diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index b2d252a..2585299 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -51,10 +51,13 @@ if (zgw.Enabled) builder.Services.AddSingleton(zgw); builder.Services.AddSingleton(); builder.Services.AddHttpClient(); + // WP-51: the documents (Documenten API / DRC) seam — same pattern as IZaakSource above. + builder.Services.AddHttpClient(); } else { builder.Services.AddSingleton(); + builder.Services.AddSingleton(); } var app = builder.Build(); @@ -177,7 +180,7 @@ api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, str // Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded // from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type // and size authoritatively; stores metadata only (no file bytes / PII held). -api.MapPost("/uploads", async (HttpRequest request) => +api.MapPost("/uploads", async (HttpRequest request, IDocumentSource documents) => { if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400); var form = await request.ReadFormAsync(); @@ -192,8 +195,11 @@ api.MapPost("/uploads", async (HttpRequest request) => using var ms = new MemoryStream(); await file.CopyToAsync(ms); - var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner); - return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId)); + // WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add + // call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers + // the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way. + var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner); + return Results.Created($"/api/v1/uploads/{response.DocumentId}", response); }) .ExcludeFromDescription(); @@ -299,7 +305,7 @@ api.MapDelete("/applications/{id}", (string id) => // 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, IZaakSource zaken) => +api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) => { var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner); if (existing is null) return Results.NotFound(); @@ -314,9 +320,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re }; 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 documentIds = docs?.Where(d => d.Channel == "digital" && 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(); @@ -329,7 +333,14 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re // of what was computed above; OpenZaakZaakSource (Zgw:Enabled=true) also registers a zaak // in OpenZaak and maps its result back into this same response shape (ADR-0001/ADR-0005: // zero FE contract change either way). - var (referentie, status) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow); + var (referentie, status, zaakUrl) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow); + if (zaakUrl is not null) ApplicationStore.SetZaakUrl(id, zaakUrl); + + // WP-51: link the submitted documents to the zaak — LocalDocumentSource is exactly the + // DocumentStore.Link call this used to make inline; OpenZaakDocumentSource additionally + // POSTs a zaakinformatieobject per document, now that the zaak (zaakUrl) exists. + if (documentIds is not null) documents.LinkToZaak(documentIds, zaakUrl); + return Results.Ok(new SubmitApplicationResponse(referentie, status)); }) .Produces() diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs new file mode 100644 index 0000000..9ff9060 --- /dev/null +++ b/backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using BigRegister.Api.Contracts; +using BigRegister.Api.Data; + +namespace BigRegister.Api.Zgw; + +/// +/// The backed by a real OpenZaak / ZGW Documenten API (DRC, +/// WP-51). An upload always lands locally first ( stays the record +/// of truth for preview/download/audit, same reasoning as 's +/// dual-write for aanvragen, WP-50) and is then ALSO registered as a DRC +/// enkelvoudiginformatieobject, whose url is persisted () +/// so can find it later without a re-upload. Selected only when +/// Zgw:Enabled=true; the default stays . +/// +/// Auth: a fresh HS256 JWT per request (), same as +/// — creating a document needs write scope on Documenten; +/// linking one to a zaak needs write scope on Zaken (the zaakinformatieobject resource). +/// +public sealed class OpenZaakDocumentSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IDocumentSource +{ + private readonly ZgwHttpClient zgw = new(http, tokens); + + // ponytail: sync-over-async — IDocumentSource is sync to match the local store + the + // existing sync upload/submit endpoints, same reasoning as OpenZaakZaakSource. + public UploadResponse Upload( + string localId, string categoryId, string wizardId, string fileName, string contentType, + byte[] content, string owner) => + UploadAsync(localId, categoryId, wizardId, fileName, contentType, content, owner) + .GetAwaiter().GetResult(); + + private async Task UploadAsync( + string localId, string categoryId, string wizardId, string fileName, string contentType, + byte[] content, string owner) + { + var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, owner); + + if (!options.InformatieobjecttypeUrls.TryGetValue(categoryId, out var informatieobjecttypeUrl)) + throw new InvalidOperationException( + $"Zgw:InformatieobjecttypeUrls has no entry for category '{categoryId}'."); + + var eio = await zgw.PostAsync($"{options.DrcBaseUrl}/enkelvoudiginformatieobjecten", new CreateEioRequest( + Bronorganisatie: options.Bronorganisatie, + Creatiedatum: DateOnly.FromDateTime(doc.UploadedAt.UtcDateTime), + Titel: fileName, + Auteur: options.UserRepresentation, + Taal: "nld", + Formaat: contentType, + Bestandsnaam: fileName, + Inhoud: Convert.ToBase64String(content), + Informatieobjecttype: informatieobjecttypeUrl, + Identificatie: doc.DocumentId, + // ponytail: hardcoded "openbaar" (public) — real usage would likely vary the + // confidentiality level per category (e.g. an identity document is more sensitive + // than a diploma); a fixed value is enough to prove the seam end-to-end. + Vertrouwelijkheidaanduiding: "openbaar")); + + DocumentStore.SetDrcUrl(doc.DocumentId, eio.Url); + return new UploadResponse(doc.DocumentId, doc.LocalId); + } + + /// Local link always happens (dual-write, same reasoning as upload); additionally, + /// once a zaak exists, POST a zaakinformatieobject for every document that has a DRC url — + /// documents uploaded before Zgw:Enabled was ever true (or under a config gap) simply have + /// no DrcUrl yet and are skipped, matching "nothing extra to link" for the local case. + public void LinkToZaak(IReadOnlyList documentIds, string? zaakUrl) + { + DocumentStore.Link(documentIds); + if (zaakUrl is null) return; + LinkToZaakAsync(documentIds, zaakUrl).GetAwaiter().GetResult(); + } + + private async Task LinkToZaakAsync(IReadOnlyList documentIds, string zaakUrl) + { + foreach (var documentId in documentIds) + { + var drcUrl = DocumentStore.Get(documentId)?.DrcUrl; + if (drcUrl is null) continue; + await zgw.PostAsync($"{options.ZrcBaseUrl}/zaakinformatieobjecten", + new CreateZaakInformatieobjectRequest(zaakUrl, drcUrl)); + } + } + + private sealed record Eio([property: JsonPropertyName("url")] string Url); + + private sealed record CreateEioRequest( + [property: JsonPropertyName("bronorganisatie")] string Bronorganisatie, + [property: JsonPropertyName("creatiedatum")] DateOnly Creatiedatum, + [property: JsonPropertyName("titel")] string Titel, + [property: JsonPropertyName("auteur")] string Auteur, + [property: JsonPropertyName("taal")] string Taal, + [property: JsonPropertyName("formaat")] string Formaat, + [property: JsonPropertyName("bestandsnaam")] string Bestandsnaam, + [property: JsonPropertyName("inhoud")] string Inhoud, + [property: JsonPropertyName("informatieobjecttype")] string Informatieobjecttype, + [property: JsonPropertyName("identificatie")] string Identificatie, + [property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding); + + private sealed record CreateZaakInformatieobjectRequest( + [property: JsonPropertyName("zaak")] string Zaak, + [property: JsonPropertyName("informatieobject")] string Informatieobject); +} diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs index e095d30..024773f 100644 --- a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs +++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs @@ -1,5 +1,3 @@ -using System.Net.Http.Headers; -using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; using BigRegister.Api.Contracts; @@ -27,6 +25,8 @@ public sealed record ZgwPage( /// public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource { + private readonly ZgwHttpClient zgw = new(http, tokens); + // ponytail: sync-over-async — IZaakSource is sync to match the local store + the existing // sync /admin/cases endpoint, and ASP.NET Core has no sync-context to deadlock on. Make the // whole cases read path async (endpoint + CasesAdmin + interface) if OpenZaak becomes the @@ -55,7 +55,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, string? next = url; while (next is not null) { - var page = await GetAsync>(next); + var page = await zgw.GetAsync>(next); all.AddRange(page.Results); next = page.Next; } @@ -65,21 +65,10 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, /// A zaaktype's human label (omschrijving) from the Catalogi API. private async Task ZaaktypeLabelAsync(string zaaktypeUrl) { - var zt = await GetAsync(zaaktypeUrl); + var zt = await zgw.GetAsync(zaaktypeUrl); return zt.Omschrijving; } - private async Task GetAsync(string url) - { - using var req = new HttpRequestMessage(HttpMethod.Get, url); - req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Mint()); - req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - using var res = await http.SendAsync(req); - res.EnsureSuccessStatusCode(); - return (await res.Content.ReadFromJsonAsync()) - ?? throw new InvalidOperationException($"ZGW GET {url} returned null body."); - } - // --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------ /// Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the @@ -91,16 +80,16 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, /// already marked Submitted locally (ApplicationStore.Submit already ran) but has no zaak. /// Acceptable for a first write slice against a demo backend; a production arc would need a /// retry/reconciliation story (or an outbox) before this dual-write can be trusted. - public (string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) => + public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) => CreateZaakAsync(aanvraag, now).GetAwaiter().GetResult(); - private async Task<(string Referentie, AanvraagStatusDto Status)> CreateZaakAsync(Aanvraag aanvraag, DateTimeOffset now) + private async Task<(string Referentie, AanvraagStatusDto Status, string? ZaakUrl)> CreateZaakAsync(Aanvraag aanvraag, DateTimeOffset now) { if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl)) throw new InvalidOperationException( $"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'."); - var zaak = await PostAsync($"{options.ZrcBaseUrl}/zaken", new CreateZaakRequest( + var zaak = await zgw.PostAsync($"{options.ZrcBaseUrl}/zaken", new CreateZaakRequest( Zaaktype: zaaktypeUrl, Bronorganisatie: options.Bronorganisatie, VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie, @@ -109,18 +98,18 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ?? throw new InvalidOperationException("Aanvraag has no Referentie yet — submit it locally first."))); var statustypeUrl = await FirstStatustypeUrlAsync(zaaktypeUrl); - await PostAsync($"{options.ZrcBaseUrl}/statussen", + await zgw.PostAsync($"{options.ZrcBaseUrl}/statussen", new CreateStatusRequest(zaak.Url, statustypeUrl, now)); var roltypeUrl = await FirstInitiatorRoltypeUrlAsync(zaaktypeUrl); - await PostAsync($"{options.ZrcBaseUrl}/rollen", new CreateRolRequest( + await zgw.PostAsync($"{options.ZrcBaseUrl}/rollen", new CreateRolRequest( Zaak: zaak.Url, BetrokkeneType: "natuurlijk_persoon", Roltype: roltypeUrl, Roltoelichting: "Initiator", BetrokkeneIdentificatie: new BetrokkeneIdentificatie(aanvraag.Owner))); - return (zaak.Identificatie, ZgwZaakMapper.ToCreatedStatusDto(zaak.Identificatie)); + return (zaak.Identificatie, ZgwZaakMapper.ToCreatedStatusDto(zaak.Identificatie), zaak.Url); } // ponytail: takes the first statustype (lowest volgnummer) / the first "initiator" roltype @@ -129,7 +118,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, // initiator role (the normal case); add per-type config if that ever stops holding. private async Task FirstStatustypeUrlAsync(string zaaktypeUrl) { - var page = await GetAsync>( + var page = await zgw.GetAsync>( $"{options.ZtcBaseUrl}/statustypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}"); var first = page.Results.OrderBy(s => s.Volgnummer).FirstOrDefault() ?? throw new InvalidOperationException($"No statustype found for zaaktype {zaaktypeUrl}."); @@ -138,24 +127,13 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, private async Task FirstInitiatorRoltypeUrlAsync(string zaaktypeUrl) { - var page = await GetAsync>( + var page = await zgw.GetAsync>( $"{options.ZtcBaseUrl}/roltypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}&omschrijvingGeneriek=initiator"); var first = page.Results.FirstOrDefault() ?? throw new InvalidOperationException($"No 'initiator' roltype found for zaaktype {zaaktypeUrl}."); return first.Url; } - private async Task PostAsync(string url, object body) - { - using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) }; - req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Mint()); - req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - using var res = await http.SendAsync(req); - res.EnsureSuccessStatusCode(); - return (await res.Content.ReadFromJsonAsync()) - ?? throw new InvalidOperationException($"ZGW POST {url} returned null body."); - } - private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving); private sealed record Statustype( diff --git a/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs b/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs new file mode 100644 index 0000000..0f0df07 --- /dev/null +++ b/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs @@ -0,0 +1,39 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; + +namespace BigRegister.Api.Zgw; + +/// +/// Shared GET/POST-with-Bearer-JWT plumbing for the ZGW source classes. Factored out of +/// once OpenZaakDocumentSource (WP-51) needed the +/// identical auth + JSON + error-handling boilerplate — every ZGW call mints a fresh token +/// () and expects/returns JSON. +/// +internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens) +{ + public async Task GetAsync(string url) + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + Authorize(req); + using var res = await http.SendAsync(req); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync()) + ?? throw new InvalidOperationException($"ZGW GET {url} returned null body."); + } + + public async Task PostAsync(string url, object body) + { + using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) }; + Authorize(req); + using var res = await http.SendAsync(req); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync()) + ?? throw new InvalidOperationException($"ZGW POST {url} returned null body."); + } + + private void Authorize(HttpRequestMessage req) + { + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Mint()); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } +} diff --git a/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs index 8f1d495..8011e9f 100644 --- a/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs +++ b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs @@ -6,9 +6,10 @@ namespace BigRegister.Api.Zgw; /// SQLite store; set Zgw:Enabled=true (plus the URLs + credentials) to source cases /// from a real OpenZaak. /// -/// The ZGW standard is FIVE separate services, each its own base URL — Slice 1 only needs -/// the Zaken API (ZRC) and, to resolve human labels for a zaaktype, the Catalogi API (ZTC). -/// The others (DRC/BRC/NRC) arrive with later slices (WP-51/52). +/// The ZGW standard is FIVE separate services, each its own base URL — slice 1 (WP-49) only +/// needed the Zaken API (ZRC) and, to resolve human labels for a zaaktype, the Catalogi API +/// (ZTC). WP-50 (create-zaak) stayed on those two; WP-51 adds the Documenten API (DRC). +/// BRC/NRC arrive with later slices (WP-52+). /// public sealed class ZgwOptions { @@ -43,4 +44,12 @@ public sealed class ZgwOptions /// RSIN of the organisation responsible for the zaak (verantwoordelijkeOrganisatie, /// WP-50) — usually the same RSIN as . public string VerantwoordelijkeOrganisatie { get; init; } = ""; + + /// Documenten API (DRC) base URL, e.g. https://open-zaak.example/documenten/api/v1 (WP-51). + public string DrcBaseUrl { get; init; } = ""; + + /// Upload CategoryId (diploma/identiteit/taalvaardigheid/...) → informatieobjecttype + /// URL (Catalogi), so create-document (WP-51) knows which type to register per category — + /// the document analogue of . + public Dictionary InformatieobjecttypeUrls { get; init; } = new(); } diff --git a/backend/tests/BigRegister.Tests/OpenZaakDocumentSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakDocumentSourceTests.cs new file mode 100644 index 0000000..5f95a5e --- /dev/null +++ b/backend/tests/BigRegister.Tests/OpenZaakDocumentSourceTests.cs @@ -0,0 +1,108 @@ +using BigRegister.Api.Data; +using BigRegister.Api.Zgw; + +namespace BigRegister.Tests; + +/// +/// Exercises the OpenZaak document source against a stub HttpMessageHandler (WP-51): an +/// upload registers a DRC enkelvoudiginformatieobject, and linking to a zaak POSTs a +/// zaakinformatieobject per document once a zaak URL is known. +/// +public class OpenZaakDocumentSourceTests +{ + private const string DrcBase = "https://oz.example/documenten/api/v1"; + private const string ZrcBase = "https://oz.example/zaken/api/v1"; + private const string ZaaktypeUrl = "https://oz.example/catalogi/api/v1/zaaktypen/zt-registratie"; + private const string InformatieobjecttypeUrl = "https://oz.example/catalogi/api/v1/informatieobjecttypen/iot-identiteit"; + + private static ZgwOptions Options() => new() + { + DrcBaseUrl = DrcBase, + ZrcBaseUrl = ZrcBase, + ClientId = "c", + Secret = "s", + Bronorganisatie = "123443210", + UserRepresentation = "BIG-register BFF", + InformatieobjecttypeUrls = new() { ["identiteit"] = InformatieobjecttypeUrl }, + }; + + [Fact] + public void Upload_registers_an_eio_in_drc_and_persists_its_url_locally() + { + var options = Options(); + var handler = new ZgwStubHandler(url => url switch + { + _ when url == $"{DrcBase}/enkelvoudiginformatieobjecten" => + """{ "url": "https://oz.example/documenten/api/v1/enkelvoudiginformatieobjecten/eio-1" }""", + _ => throw new InvalidOperationException($"unexpected ZGW call {url}"), + }); + var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options); + + var response = source.Upload("local-1", "identiteit", "registratie", "paspoort.pdf", "application/pdf", + "%PDF-1.4 fake"u8.ToArray(), "111222333"); + + Assert.Equal("local-1", response.LocalId); + Assert.NotEmpty(response.DocumentId); + + // Registered locally too (dual-write, same reasoning as CreateZaak/WP-50) — content + // preview/download keeps working regardless of Zgw:Enabled. + var stored = DocumentStore.Get(response.DocumentId); + Assert.NotNull(stored); + Assert.Equal("https://oz.example/documenten/api/v1/enkelvoudiginformatieobjecten/eio-1", stored!.DrcUrl); + + var body = handler.BodyOf($"{DrcBase}/enkelvoudiginformatieobjecten"); + Assert.Contains(InformatieobjecttypeUrl, body); + Assert.Contains("123443210", body); // bronorganisatie + Assert.Contains("paspoort.pdf", body); + Assert.Contains(Convert.ToBase64String("%PDF-1.4 fake"u8.ToArray()), body); // inhoud + } + + [Fact] + public void Upload_throws_when_the_category_has_no_configured_informatieobjecttype() + { + var options = Options(); + var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}")); + var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options); + + Assert.Throws(() => + source.Upload("local-1", "unknown-category", "registratie", "f.pdf", "application/pdf", [1, 2, 3], "111222333")); + } + + [Fact] + public void LinkToZaak_posts_a_zaakinformatieobject_per_document_once_a_zaak_exists() + { + var options = Options(); + var uploadHandler = new ZgwStubHandler(url => + """{ "url": "https://oz.example/documenten/api/v1/enkelvoudiginformatieobjecten/eio-1" }"""); + var uploader = new OpenZaakDocumentSource(new HttpClient(uploadHandler), new ZgwTokenProvider(options), options); + var doc = uploader.Upload("local-1", "identiteit", "registratie", "paspoort.pdf", "application/pdf", [1, 2, 3], "111222333"); + + var linkHandler = new ZgwStubHandler(url => url switch + { + _ when url == $"{ZrcBase}/zaakinformatieobjecten" => "{}", + _ => throw new InvalidOperationException($"unexpected ZGW call {url}"), + }); + var linker = new OpenZaakDocumentSource(new HttpClient(linkHandler), new ZgwTokenProvider(options), options); + + linker.LinkToZaak([doc.DocumentId], $"{ZrcBase}/zaken/uuid-1"); + + var body = linkHandler.BodyOf($"{ZrcBase}/zaakinformatieobjecten"); + Assert.Contains($"{ZrcBase}/zaken/uuid-1", body); + Assert.Contains("eio-1", body); + + // Local link also happened (dual-write) — the document is now Linked (delete blocked). + Assert.Equal(DocumentStore.DeleteResult.Linked, DocumentStore.DeleteOwned(doc.DocumentId, "111222333")); + } + + [Fact] + public void LinkToZaak_makes_no_zgw_call_when_the_local_source_created_no_zaak() + { + var options = Options(); + var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}")); + var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options); + + source.LinkToZaak(["some-document-id"], zaakUrl: null); + + Assert.Empty(handler.Requests); + } +} diff --git a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs index 3edeaac..ff15923 100644 --- a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs +++ b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs @@ -1,5 +1,3 @@ -using System.Net; -using System.Text; using BigRegister.Api.Data; using BigRegister.Api.Zgw; @@ -34,7 +32,7 @@ public class OpenZaakZaakSourceTests [Fact] public void Follows_pagination_caches_zaaktype_and_sends_bearer_token() { - var handler = new StubHandler(url => url switch + var handler = new ZgwStubHandler(url => url switch { _ when url == $"{ZrcBase}/zaken" => Page1, _ when url == $"{ZrcBase}/zaken?page=2" => Page2, @@ -64,7 +62,7 @@ public class OpenZaakZaakSourceTests public void CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back() { const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie"; - var handler = new StubHandler(url => url switch + var handler = new ZgwStubHandler(url => url switch { _ when url == $"{ZrcBase}/zaken" => $$""" { "url": "{{ZrcBase}}/zaken/uuid-new", "identificatie": "BIG-2026-000123", @@ -103,27 +101,26 @@ public class OpenZaakZaakSourceTests Referentie = "BIG-2026-000123", }; - var (referentie, status) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero)); + var (referentie, status, zaakUrl) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero)); Assert.Equal("BIG-2026-000123", referentie); Assert.Equal("InBehandeling", status.Tag); Assert.Equal("BIG-2026-000123", status.Referentie); - - string BodyOf(string url) => handler.Bodies[handler.Requests.LastIndexOf(url)]; + Assert.Equal($"{ZrcBase}/zaken/uuid-new", zaakUrl); // Zaak: mapped zaaktype + configured RSINs + the local reference as identificatie. - var zaakBody = BodyOf($"{ZrcBase}/zaken"); + var zaakBody = handler.BodyOf($"{ZrcBase}/zaken"); Assert.Contains(zaaktypeUrl, zaakBody); Assert.Contains("123443210", zaakBody); Assert.Contains("BIG-2026-000123", zaakBody); // Status: points at the created zaak's URL and the resolved statustype. - var statusBody = BodyOf($"{ZrcBase}/statussen"); + var statusBody = handler.BodyOf($"{ZrcBase}/statussen"); Assert.Contains($"{ZrcBase}/zaken/uuid-new", statusBody); Assert.Contains("statustypen/st-1", statusBody); // Rol: points at the created zaak, the resolved initiator roltype, and the BSN. - var rolBody = BodyOf($"{ZrcBase}/rollen"); + var rolBody = handler.BodyOf($"{ZrcBase}/rollen"); Assert.Contains($"{ZrcBase}/zaken/uuid-new", rolBody); Assert.Contains("roltypen/rt-initiator", rolBody); Assert.Contains("111222333", rolBody); @@ -133,29 +130,10 @@ public class OpenZaakZaakSourceTests public void CreateZaak_throws_when_the_aanvraag_type_has_no_configured_zaaktype() { var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" }; - var handler = new StubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}")); + var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}")); var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", Referentie = "BIG-2026-000123" }; Assert.Throws(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow)); } - - private sealed class StubHandler(Func respond) : HttpMessageHandler - { - public List Requests { get; } = new(); - public List AuthSchemes { get; } = new(); - public List Bodies { get; } = new(); - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var url = request.RequestUri!.ToString(); - Requests.Add(url); - AuthSchemes.Add(request.Headers.Authorization?.Scheme); - Bodies.Add(request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult() ?? ""); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(respond(url), Encoding.UTF8, "application/json"), - }); - } - } } diff --git a/backend/tests/BigRegister.Tests/ZgwStubHandler.cs b/backend/tests/BigRegister.Tests/ZgwStubHandler.cs new file mode 100644 index 0000000..dfe67d3 --- /dev/null +++ b/backend/tests/BigRegister.Tests/ZgwStubHandler.cs @@ -0,0 +1,32 @@ +using System.Net; +using System.Text; + +namespace BigRegister.Tests; + +/// +/// Stub HttpMessageHandler shared by the ZGW source tests (no live server, no mocking +/// library) — keyed purely by request URL (method-agnostic, since no test scenario reuses a +/// URL across GET/POST). Records every request's url/body/auth-scheme for assertion. +/// Factored out of OpenZaakZaakSourceTests once OpenZaakDocumentSourceTests needed the +/// identical stub. +/// +internal sealed class ZgwStubHandler(Func respond) : HttpMessageHandler +{ + public List Requests { get; } = new(); + public List AuthSchemes { get; } = new(); + public List Bodies { get; } = new(); + + public string BodyOf(string url) => Bodies[Requests.LastIndexOf(url)]; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri!.ToString(); + Requests.Add(url); + AuthSchemes.Add(request.Headers.Authorization?.Scheme); + Bodies.Add(request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult() ?? ""); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(respond(url), Encoding.UTF8, "application/json"), + }); + } +} diff --git a/docs/reference/openzaak-integration.md b/docs/reference/openzaak-integration.md index 0d87f89..f5fd865 100644 --- a/docs/reference/openzaak-integration.md +++ b/docs/reference/openzaak-integration.md @@ -1,10 +1,12 @@ # OpenZaak / ZGW integration — how the BFF connects (& how to extend) -How the BFF sources (and now creates) cases against a real **OpenZaak** (ZGW APIs) while the -frontend stays unchanged. For the _why_, see [ADR-0005](architecture/0005-openzaak-behind-bff.md); -this page is _how the seam is built and how to add the next slice_. Built in -[WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (read-only zaken) and -[WP-50](../project/backlog/WP-50-openzaak-create-zaak.md) (the first write: create-zaak). +How the BFF sources (and now creates) cases, and uploads/links documents, against a real +**OpenZaak** (ZGW APIs) while the frontend stays unchanged. For the _why_, see +[ADR-0005](architecture/0005-openzaak-behind-bff.md); this page is _how the seam is built and +how to add the next slice_. Built in +[WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (read-only zaken), +[WP-50](../project/backlog/WP-50-openzaak-create-zaak.md) (create-zaak), and +[WP-51](../project/backlog/WP-51-openzaak-documenten.md) (Documenten/DRC upload + zaak link). ## The one rule: OpenZaak sits behind the BFF, never in the browser @@ -17,15 +19,21 @@ with **zero frontend change and no api-client drift**. - `Data/IZaakSource.cs` — the cases READ + (WP-50) WRITE interface: `ListCases` and `CreateZaak`. Both return the existing DTOs, so each implementation owns its own mapping. + `CreateZaak` also returns the zaak's URL (`ZaakUrl`, null under the local source) so WP-51 + can later link documents to it. - `Data/LocalZaakSource.cs` — **default**; reads the local SQLite `ApplicationStore` (offline, unchanged behaviour). `CreateZaak` is a pure passthrough of what the submit endpoint already computed locally — no external call. - `Zgw/OpenZaakZaakSource.cs` — the OpenZaak client; selected only when `Zgw:Enabled=true`. `CreateZaak` posts a Zaak, then a Status, then a Rol (see below). -- Wiring (`Program.cs`): `if (Zgw:Enabled) AddHttpClient() -else AddSingleton()`. The `/admin/cases` GET and the - `/applications/{id}/submit` POST both resolve `IZaakSource` from DI — routes + DTOs - untouched either way. +- `Data/IDocumentSource.cs` — the documents seam (WP-51), sibling of `IZaakSource`: `Upload` + and `LinkToZaak`. `Data/LocalDocumentSource.cs` is the same `DocumentStore.Add`/`Link` calls + the upload/submit endpoints used to make inline; `Zgw/OpenZaakDocumentSource.cs` also + registers each upload as a DRC document and links it to a zaak once one exists. +- Wiring (`Program.cs`): `if (Zgw:Enabled)` registers `OpenZaakZaakSource` + + `OpenZaakDocumentSource`, else `LocalZaakSource` + `LocalDocumentSource`. The `/admin/cases` + GET, the `/uploads` POST, and the `/applications/{id}/submit` POST all resolve their seam + from DI — routes + DTOs untouched either way. ## Create-zaak (WP-50) — the first write @@ -33,9 +41,10 @@ else AddSingleton()`. The `/admin/cases` GET and t — unconditionally, regardless of `Zgw:Enabled`, since draft/step/document bookkeeping stays local either way) and only THEN calls `zaken.CreateZaak(submitted, now)`. The submit endpoint never branches on `Zgw:Enabled` itself — DI already picked the implementation, so the endpoint -just asks the seam for `(Referentie, Status)` and returns exactly that in the unchanged -`SubmitApplicationResponse`. Under the default (local) source this returns precisely what was -just computed; under OpenZaak, three calls happen in order: +just asks the seam for `(Referentie, Status, ZaakUrl)` and returns the first two, unchanged, in +`SubmitApplicationResponse` (`ZaakUrl` is persisted via `ApplicationStore.SetZaakUrl` for +WP-51's document link, not returned to the FE). Under the default (local) source this returns +precisely what was just computed; under OpenZaak, three calls happen in order: 1. **POST zaak** (`{ZrcBaseUrl}/zaken`) — `zaaktype` resolved from `Zgw:ZaaktypeUrls[aanvraag.Type]` (OpenZaak validates the URL by fetching it), `bronorganisatie`/`verantwoordelijkeOrganisatie` @@ -58,19 +67,47 @@ status and initiator role; (b) no compensating transaction — if any ZGW call t aanvraag is already `Submitted` locally with no matching zaak (acceptable for a demo backend; a production arc needs retry/reconciliation or an outbox before trusting this dual-write). +## Documenten / DRC upload + zaak link (WP-51) + +`POST /uploads` and `POST /applications/{id}/submit` route through `IDocumentSource` the same +way submit routes through `IZaakSource`: the local write (`DocumentStore.Add`/`Link`) always +happens first — it stays the record of truth for preview/download/audit regardless of +`Zgw:Enabled` — and `OpenZaakDocumentSource` additionally does the DRC side-effect: + +1. **Upload** — POST `enkelvoudiginformatieobjecten` (`{DrcBaseUrl}`) with the file's base64 + content, `informatieobjecttype` resolved from `Zgw:InformatieobjecttypeUrls[categoryId]` + (the document analogue of `ZaaktypeUrls`), `identificatie` set to the local document id. The + returned DRC url is persisted (`DocumentStore.SetDrcUrl`) so the link step below doesn't + need to re-upload. +2. **Link to zaak** — once `IZaakSource.CreateZaak` has returned a `ZaakUrl` (persisted via + `ApplicationStore.SetZaakUrl`), submit calls `documents.LinkToZaak(documentIds, zaakUrl)`, + which POSTs a `zaakinformatieobjecten` (`{ZrcBaseUrl}`) per document that has a `DrcUrl`. + Documents uploaded before a zaak existed (or under a config gap) have no `DrcUrl` yet and + are silently skipped — same "nothing extra to link" behaviour as the local source. + +`ZgwHttpClient` (shared GET/POST-with-bearer-JWT plumbing) was factored out of +`OpenZaakZaakSource` once `OpenZaakDocumentSource` needed the identical boilerplate. + +ponytail shortcut: `vertrouwelijkheidaanduiding` is hardcoded to `"openbaar"` — a per-category +confidentiality level would matter for production but isn't needed to prove the seam. + ## The ZGW client (`backend/src/BigRegister.Api/Zgw/`) - `ZgwOptions.cs` — bound from the `Zgw` appsettings section: `Enabled`, per-service base URLs - (`ZrcBaseUrl`, `ZtcBaseUrl`), `ClientId`, `Secret`, `UserId`, `UserRepresentation`. The five - ZGW APIs are separate base URLs; slice 1 needs only Zaken (ZRC) + Catalogi (ZTC). + (`ZrcBaseUrl`, `ZtcBaseUrl`, `DrcBaseUrl`), `ClientId`, `Secret`, `UserId`, + `UserRepresentation`. The five ZGW APIs are separate base URLs; slices 1–3 need Zaken (ZRC), + Catalogi (ZTC), and Documenten (DRC). - `ZgwTokenProvider.cs` — mints an **HS256 JWT per call** (`iss`/`client_id`/`iat`/`user_id`/ `user_representation`). No refresh flow — OpenZaak expires tokens 1h past `iat`, so per-call minting is the recommended pattern. Hand-rolled (no `Microsoft.IdentityModel.*` dependency). +- `ZgwHttpClient.cs` — shared GET/POST-with-bearer-JWT plumbing used by both + `OpenZaakZaakSource` and `OpenZaakDocumentSource`. - `ZgwZaakMapper.cs` — the anti-corruption map: ZGW Zaak → `ApplicationSummaryDto`. This is where **URL identity** becomes the trailing uuid and the **zaaktype URL** is resolved to a human label (the cross-service join). - `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves + caches zaaktype labels, attaches `Authorization: Bearer `. +- `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern. ## The five ZGW APIs (context for later slices) @@ -84,22 +121,24 @@ a production arc needs retry/reconciliation or an outbox before trusting this du ## How to add the next slice -1. **Read** — extend `IZaakSource` (or add a sibling interface, e.g. `IDocumentSource`) with - the new operation; implement it on both `LocalZaakSource` and the OpenZaak source. Keep the - return type the existing DTO so the FE never changes. -2. **Write** (create-zaak, WP-50) — a create needs a `zaaktype` URL from Catalogi (OpenZaak - validates it by fetching), then usually a follow-up `status` + `rol`. Route it through the - existing submit/mutation seam. +1. **Read** — extend `IZaakSource` (or add a sibling interface, like `IDocumentSource`, WP-51) + with the new operation; implement it on both the local store and the OpenZaak source. Keep + the return type the existing DTO so the FE never changes. +2. **Write** (create-zaak WP-50, DRC upload/link WP-51) — a create/upload needs a type URL + from Catalogi (OpenZaak validates it by fetching), then usually a follow-up call (`status` + + `rol` for a zaak; `zaakinformatieobject` for a document). Route it through the existing + submit/mutation seam. 3. **Enforce server-side** for anything the FE gates — a config value the FE echoes is never the authority (ADR-0001). ## Coupling -Low and one-directional. Consumer coupling is near zero — `IZaakSource` is injected at one -endpoint, and the FE is fully decoupled by the DTO. The producer side is contained in `Zgw/`: -add a slice by adding a source method + a mapper case, not by touching the FE or the contract. -Watch the **sync-over-async** `ponytail:` note in `OpenZaakZaakSource` — make the cases read -path async if OpenZaak becomes the default. +Low and one-directional. Consumer coupling is near zero — `IZaakSource`/`IDocumentSource` are +each injected at one endpoint, and the FE is fully decoupled by the DTO. The producer side is +contained in `Zgw/`: add a slice by adding a source method + a mapper case, not by touching the +FE or the contract. Watch the **sync-over-async** `ponytail:` note in `OpenZaakZaakSource` (and +its `OpenZaakDocumentSource` sibling) — make the read/write paths async if OpenZaak becomes the +default. ## Config @@ -109,6 +148,7 @@ path async if OpenZaak becomes the default. "Enabled": true, "ZrcBaseUrl": "https://open-zaak.example/zaken/api/v1", "ZtcBaseUrl": "https://open-zaak.example/catalogi/api/v1", + "DrcBaseUrl": "https://open-zaak.example/documenten/api/v1", "ClientId": "big-register", "Secret": "", "UserId": "", "UserRepresentation": "", // WP-50 (create-zaak): RSINs + the aanvraag-type → zaaktype URL map. @@ -117,6 +157,11 @@ path async if OpenZaak becomes the default. "registratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/", "herregistratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/", "intake": "https://open-zaak.example/catalogi/api/v1/zaaktypen/" + }, + // WP-51 (Documenten): upload category → informatieobjecttype URL map. + "InformatieobjecttypeUrls": { + "identiteit": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/", + "diploma": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/" } } ``` @@ -153,17 +198,18 @@ Principles this demonstrates: comment in `ZgwZaakMapper` show where the ACL is deliberately thin — an ACL need not be complete on day one, but its shortcuts should be visible. -Caveat: `IZaakSource` now covers the cases **read + create** path (WP-49/50). Other BFF -endpoints still read `SeedData`/static stores directly — ACL-ready (the DTO seam exists) but not -yet swappable. That is the WP-51/52 roadmap, plus the two cross-cutting WPs the arc needs for -production: **WP-53** (a real per-request identity seam + citizen-scoping — today the owner/BSN -is stubbed) and **WP-54** (a docker OpenZaak harness + opt-in integration test — today everything -is fixture/mock-tested against no live instance). +Caveat: `IZaakSource` covers the cases **read + create** path (WP-49/50) and `IDocumentSource` +covers **upload + zaak-link** (WP-51). Other BFF endpoints still read `SeedData`/static stores +directly — ACL-ready (the DTO seam exists) but not yet swappable. That is the WP-52 roadmap +(notificaties), plus the two cross-cutting WPs the arc needs for production: **WP-53** (a real +per-request identity seam + citizen-scoping — today the owner/BSN is stubbed) and **WP-54** (a +docker OpenZaak harness + opt-in integration test — today everything is fixture/mock-tested +against no live instance). ## See also - [ADR-0005 — OpenZaak behind the BFF](architecture/0005-openzaak-behind-bff.md) — the decision. - [ADR-0001 — BFF-lite + decision DTOs](architecture/0001-bff-lite-decision-dtos.md) — why the FE doesn't change. -- [WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (this), WP-50/51/52 (CRUD arc), WP-53/54 (identity seam + integration harness). -- `backend/src/BigRegister.Api/Zgw/` — the client; `Data/IZaakSource.cs` — the seam. +- [WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (this), WP-50/51 (CRUD arc so far), WP-52 (notificaties), WP-53/54 (identity seam + integration harness). +- `backend/src/BigRegister.Api/Zgw/` — the client; `Data/IZaakSource.cs`/`Data/IDocumentSource.cs` — the seams. - [ZGW standard (VNG)](https://vng-realisatie.github.io/gemma-zaken/) · [OpenZaak auth docs](https://open-zaak.readthedocs.io/en/stable/client-development/authentication.html).