Compare commits

...
4 Commits
Author SHA1 Message Date
ehoandClaude Sonnet 5 e75550d136 docs(backlog): mark WP-51 done
CI / frontend (push) Successful in 2m10s
CI / backend (push) Failing after 59s
CI / e2e (push) Failing after 3m22s
CI / storybook-a11y (push) Failing after 7m23s
CI / semgrep (push) Successful in 1m3s
CI / api-client-drift (push) Successful in 1m55s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 20:54:52 +02:00
ehoandClaude Sonnet 5 5807937229 feat(zgw): OpenZaak Documenten (DRC) upload + zaak link (WP-51)
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 <noreply@anthropic.com>
2026-07-29 20:54:31 +02:00
ehoandClaude Sonnet 5 3671684528 docs(backlog): mark WP-50 done
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 09:03:32 +02:00
ehoandClaude Sonnet 5 de3bff0d7f feat(zgw): OpenZaak create-zaak, first write slice (WP-50)
Extends the IZaakSource seam (WP-49, read-only) with CreateZaak: submitting
an aanvraag now also registers a Zaak + Status + Rol in OpenZaak when
Zgw:Enabled=true, routed through the existing /applications/{id}/submit
endpoint with the FE response DTO unchanged (ADR-0001/ADR-0005 — the
endpoint never branches on the config flag itself, DI already picked the
implementation).

- ZgwOptions gains a Type→zaaktype-URL map + the two RSINs a Zaak needs.
- LocalZaakSource.CreateZaak is a pure passthrough of what the endpoint
  already computes locally (zero behaviour change for the offline default).
- OpenZaakZaakSource.CreateZaak POSTs the zaak (identificatie = the same
  local reference, so both stay in sync), resolves + POSTs the initial
  status and the initiator rol (BSN) via Catalogi lookups, and maps the
  result back into the submit response.
- Marked ponytail shortcuts: first-statustype/roltype-Catalogi-returns
  (no per-type config) and no compensating transaction on partial failure
  — both fine for a first slice against a demo backend.

Verified: full `npm run ci` green, zero api-client drift, 144/144 backend
tests (142 existing + 2 new stub-handler tests asserting the POST bodies
+ type→zaaktype mapping per the acceptance criteria).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 09:03:13 +02:00
24 changed files with 1084 additions and 86 deletions
@@ -26,6 +26,13 @@ public sealed class Aanvraag
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? SubmittedAt { get; set; } public DateTimeOffset? SubmittedAt { get; set; }
/// <summary>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 <see cref="ApplicationStore.SetZaakUrl"/>) to keep the seam's write
/// surface at "return data", not "reach into another store".</summary>
public string? ZaakUrl { get; set; }
} }
/// <summary> /// <summary>
@@ -172,4 +179,18 @@ public static class ApplicationStore
return a; return a;
} }
} }
/// <summary>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).</summary>
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();
}
}
} }
@@ -11,6 +11,12 @@ public sealed record StoredDocument(
string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt) string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt)
{ {
public bool Linked { get; set; } public bool Linked { get; set; }
/// <summary>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
/// <see cref="Linked"/> (every existing `new StoredDocument(...)` call site keeps working).</summary>
public string? DrcUrl { get; set; }
} }
/// <summary>Id is EF Core's auto-increment key — not part of the positional /// <summary>Id is EF Core's auto-increment key — not part of the positional
@@ -69,6 +75,19 @@ public static class DocumentStore
} }
} }
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary>
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). /// Mark digital documents as linked to a finalised submission (blocks user delete).
public static void Link(IEnumerable<string> documentIds) public static void Link(IEnumerable<string> documentIds)
{ {
@@ -0,0 +1,28 @@
using BigRegister.Api.Contracts;
namespace BigRegister.Api.Data;
/// <summary>
/// The documents seam (WP-51), sibling of <see cref="IZaakSource"/>: uploads always land
/// locally first (<see cref="DocumentStore"/> stays the record of truth for preview/download/
/// audit regardless of config, exactly like <c>ApplicationStore.Submit</c> for aanvragen,
/// WP-50) — this interface is only the OpenZaak integration side-effect, selected the same way
/// (<c>Zgw:Enabled</c>). Default binding is <see cref="LocalDocumentSource"/> (offline);
/// <c>OpenZaakDocumentSource</c> also registers each upload as a DRC
/// enkelvoudiginformatieobject and links it to a zaak once one exists.
/// </summary>
public interface IDocumentSource
{
/// <summary>Store an uploaded file (already validated by <c>DocumentRules</c>) and return the
/// existing <see cref="UploadResponse"/> DTO unchanged, whichever source is active.</summary>
UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, string owner);
/// <summary>Finalise a set of already-uploaded documents against a just-submitted aanvraag
/// (WP-50/51): local behaviour is exactly today's <c>DocumentStore.Link</c>; the OpenZaak
/// source additionally links each document (that has a DRC url) to the zaak, once
/// <paramref name="zaakUrl"/> is known (null under the local <see cref="IZaakSource"/>, in
/// which case there is nothing extra to link).</summary>
void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl);
}
@@ -9,8 +9,8 @@ namespace BigRegister.Api.Data;
/// contract</em> — so the frontend never changes (BFF-lite anti-corruption, ADR-0001). /// contract</em> — so the frontend never changes (BFF-lite anti-corruption, ADR-0001).
/// ///
/// Default binding is <see cref="LocalZaakSource"/> (offline). Setting <c>Zgw:Enabled=true</c> /// Default binding is <see cref="LocalZaakSource"/> (offline). Setting <c>Zgw:Enabled=true</c>
/// swaps in <c>OpenZaakZaakSource</c>. Slice 1 is read-only; create/update stay on the /// swaps in <c>OpenZaakZaakSource</c>. Slice 1 (WP-49) was read-only; <see cref="CreateZaak"/>
/// local write path until WP-50. The interface returns the wire DTO (not the domain /// (WP-50) is the first write. The interface returns the wire DTO (not the domain
/// <see cref="Aanvraag"/>) precisely so each source owns its own mapping — the OpenZaak /// <see cref="Aanvraag"/>) precisely so each source owns its own mapping — the OpenZaak
/// source maps a ZGW Zaak into this shape, the local source maps the stored aanvraag. /// source maps a ZGW Zaak into this shape, the local source maps the stored aanvraag.
/// </summary> /// </summary>
@@ -18,4 +18,17 @@ public interface IZaakSource
{ {
/// <summary>Every case, newest-first (the admin cross-owner list, WP-36).</summary> /// <summary>Every case, newest-first (the admin cross-owner list, WP-36).</summary>
IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now); IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now);
/// <summary>
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is
/// already persisted locally (<c>ApplicationStore.Submit</c> already ran) — this is the
/// 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 (<see cref="ApplicationStore.SetZaakUrl"/>, WP-51 needs it
/// to later link documents to this zaak).
/// </summary>
(string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now);
} }
@@ -0,0 +1,23 @@
using BigRegister.Api.Contracts;
namespace BigRegister.Api.Data;
/// <summary>
/// The default <see cref="IDocumentSource"/> — uploads go only to the local SQLite
/// <see cref="DocumentStore"/>, exactly as before this seam existed (WP-51). Zero behaviour
/// change: this is the same <c>DocumentStore.Add</c>/<c>DocumentStore.Link</c> the upload/
/// submit endpoints used to call inline.
/// </summary>
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<string> documentIds, string? zaakUrl) =>
DocumentStore.Link(documentIds);
}
@@ -12,4 +12,9 @@ public sealed class LocalZaakSource : IZaakSource
{ {
public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) => public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) =>
ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList(); ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList();
/// <summary>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.</summary>
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) =>
(aanvraag.Referentie!, aanvraag.ToStatusDto(now), null);
} }
@@ -0,0 +1,279 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("AutoApprovable")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DocumentIds")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Draft")
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Reden")
.HasColumnType("TEXT");
b.Property<string>("Referentie")
.HasColumnType("TEXT");
b.Property<int>("StepCount")
.HasColumnType("INTEGER");
b.Property<int>("StepIndex")
.HasColumnType("INTEGER");
b.Property<bool>("Submitted")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ZaakUrl")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
});
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DocumentId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuditEntries");
});
modelBuilder.Entity("BigRegister.Api.Data.AuthzAuditEntry", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("At")
.HasColumnType("TEXT");
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Decision")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AuthzAudit");
});
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
{
b.Property<string>("BriefId")
.HasColumnType("TEXT");
b.Property<string>("ArchivedHtml")
.HasColumnType("TEXT");
b.Property<string>("Beroep")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrafterId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Placeholders")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sections")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("SentOrgTemplateVersion")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SubOrgId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TemplateId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("BriefId");
b.HasIndex("Owner")
.IsUnique();
b.ToTable("Briefs");
});
modelBuilder.Entity("BigRegister.Api.Data.FeatureFlagEntity", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER");
b.HasKey("Key");
b.ToTable("FeatureFlags");
});
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
{
b.Property<string>("SubOrgId")
.HasColumnType("TEXT");
b.Property<string>("Draft")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("History")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PublishedVersion")
.HasColumnType("INTEGER");
b.HasKey("SubOrgId");
b.ToTable("OrgTemplates");
});
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
{
b.Property<string>("DocumentId")
.HasColumnType("TEXT");
b.Property<string>("CategoryId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrcUrl")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Linked")
.HasColumnType("INTEGER");
b.Property<string>("LocalId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Owner")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("WizardId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("DocumentId");
b.ToTable("Documents");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BigRegister.Api.Data.Migrations
{
/// <inheritdoc />
public partial class ZaakAndDrcUrls : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DrcUrl",
table: "Documents",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ZaakUrl",
table: "Applications",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DrcUrl",
table: "Documents");
migrationBuilder.DropColumn(
name: "ZaakUrl",
table: "Applications");
}
}
}
@@ -64,6 +64,9 @@ namespace BigRegister.Api.Data.Migrations
b.Property<DateTimeOffset>("UpdatedAt") b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ZaakUrl")
.HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Applications"); b.ToTable("Applications");
@@ -235,6 +238,9 @@ namespace BigRegister.Api.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("DrcUrl")
.HasColumnType("TEXT");
b.Property<string>("FileName") b.Property<string>("FileName")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+25 -8
View File
@@ -51,10 +51,13 @@ if (zgw.Enabled)
builder.Services.AddSingleton(zgw); builder.Services.AddSingleton(zgw);
builder.Services.AddSingleton<ZgwTokenProvider>(); builder.Services.AddSingleton<ZgwTokenProvider>();
builder.Services.AddHttpClient<IZaakSource, OpenZaakZaakSource>(); builder.Services.AddHttpClient<IZaakSource, OpenZaakZaakSource>();
// WP-51: the documents (Documenten API / DRC) seam — same pattern as IZaakSource above.
builder.Services.AddHttpClient<IDocumentSource, OpenZaakDocumentSource>();
} }
else else
{ {
builder.Services.AddSingleton<IZaakSource, LocalZaakSource>(); builder.Services.AddSingleton<IZaakSource, LocalZaakSource>();
builder.Services.AddSingleton<IDocumentSource, LocalDocumentSource>();
} }
var app = builder.Build(); 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 // 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 // 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). // 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); if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
var form = await request.ReadFormAsync(); var form = await request.ReadFormAsync();
@@ -192,8 +195,11 @@ api.MapPost("/uploads", async (HttpRequest request) =>
using var ms = new MemoryStream(); using var ms = new MemoryStream();
await file.CopyToAsync(ms); await file.CopyToAsync(ms);
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner); // WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add
return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId)); // 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(); .ExcludeFromDescription();
@@ -299,7 +305,7 @@ api.MapDelete("/applications/{id}", (string id) =>
// Submit runs the server-owned rules, sets autoApprovable, and transitions the // Submit runs the server-owned rules, sets autoApprovable, and transitions the
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case. // aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) => api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) =>
{ {
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner); var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (existing is null) return Results.NotFound(); if (existing is null) return Results.NotFound();
@@ -314,9 +320,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
}; };
var docs = req.Documents; var docs = req.Documents;
if (docs is not null) var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
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 submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds); var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds);
if (submitted is null) return Results.Conflict(); if (submitted is null) return Results.Conflict();
@@ -324,7 +328,20 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
app.Logger.LogInformation( app.Logger.LogInformation(
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}", "aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie); id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
// WP-50: route the create through the IZaakSource seam — LocalZaakSource is a passthrough
// 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, 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<SubmitApplicationResponse>() .Produces<SubmitApplicationResponse>()
.ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status409Conflict)
@@ -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;
/// <summary>
/// The <see cref="IDocumentSource"/> backed by a real OpenZaak / ZGW Documenten API (DRC,
/// WP-51). An upload always lands locally first (<see cref="DocumentStore"/> stays the record
/// of truth for preview/download/audit, same reasoning as <see cref="OpenZaakZaakSource"/>'s
/// dual-write for aanvragen, WP-50) and is then ALSO registered as a DRC
/// enkelvoudiginformatieobject, whose url is persisted (<see cref="DocumentStore.SetDrcUrl"/>)
/// so <see cref="LinkToZaak"/> can find it later without a re-upload. Selected only when
/// <c>Zgw:Enabled=true</c>; the default stays <see cref="LocalDocumentSource"/>.
///
/// Auth: a fresh HS256 JWT per request (<see cref="ZgwTokenProvider"/>), same as
/// <see cref="OpenZaakZaakSource"/> — creating a document needs write scope on Documenten;
/// linking one to a zaak needs write scope on Zaken (the zaakinformatieobject resource).
/// </summary>
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<UploadResponse> 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<Eio>($"{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);
}
/// <summary>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.</summary>
public void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl)
{
DocumentStore.Link(documentIds);
if (zaakUrl is null) return;
LinkToZaakAsync(documentIds, zaakUrl).GetAwaiter().GetResult();
}
private async Task LinkToZaakAsync(IReadOnlyList<string> documentIds, string zaakUrl)
{
foreach (var documentId in documentIds)
{
var drcUrl = DocumentStore.Get(documentId)?.DrcUrl;
if (drcUrl is null) continue;
await zgw.PostAsync<JsonElement>($"{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);
}
@@ -1,5 +1,3 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
@@ -15,16 +13,20 @@ public sealed record ZgwPage<T>(
[property: JsonPropertyName("results")] IReadOnlyList<T> Results); [property: JsonPropertyName("results")] IReadOnlyList<T> Results);
/// <summary> /// <summary>
/// The <see cref="IZaakSource"/> backed by a real OpenZaak / ZGW Zaken API (WP-49). Reads /// The <see cref="IZaakSource"/> backed by a real OpenZaak / ZGW Zaken API (WP-49 read, WP-50
/// zaken (following pagination), resolves each zaaktype's human label from the Catalogi API /// write). Reads zaken (following pagination), resolves each zaaktype's human label from the
/// (cached), and maps into <see cref="ApplicationSummaryDto"/> via <see cref="ZgwZaakMapper"/>. /// Catalogi API (cached), and maps into <see cref="ApplicationSummaryDto"/> via
/// <see cref="ZgwZaakMapper"/>. Creates a zaak + status + rol for a just-submitted aanvraag.
/// Selected only when <c>Zgw:Enabled=true</c>; the default stays <see cref="LocalZaakSource"/>. /// Selected only when <c>Zgw:Enabled=true</c>; the default stays <see cref="LocalZaakSource"/>.
/// ///
/// Auth: a fresh HS256 JWT per request (<see cref="ZgwTokenProvider"/>) on the Authorization /// Auth: a fresh HS256 JWT per request (<see cref="ZgwTokenProvider"/>) on the Authorization
/// header. Reading a zaak needs read scope on BOTH Zaken and Catalogi (zaaktype resolution). /// header. Reading a zaak needs read scope on BOTH Zaken and Catalogi (zaaktype resolution);
/// creating one additionally needs write scope on Zaken.
/// </summary> /// </summary>
public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource 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 // 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 // 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 // whole cases read path async (endpoint + CasesAdmin + interface) if OpenZaak becomes the
@@ -53,7 +55,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
string? next = url; string? next = url;
while (next is not null) while (next is not null)
{ {
var page = await GetAsync<ZgwPage<T>>(next); var page = await zgw.GetAsync<ZgwPage<T>>(next);
all.AddRange(page.Results); all.AddRange(page.Results);
next = page.Next; next = page.Next;
} }
@@ -63,20 +65,101 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
/// <summary>A zaaktype's human label (<c>omschrijving</c>) from the Catalogi API.</summary> /// <summary>A zaaktype's human label (<c>omschrijving</c>) from the Catalogi API.</summary>
private async Task<string> ZaaktypeLabelAsync(string zaaktypeUrl) private async Task<string> ZaaktypeLabelAsync(string zaaktypeUrl)
{ {
var zt = await GetAsync<Zaaktype>(zaaktypeUrl); var zt = await zgw.GetAsync<Zaaktype>(zaaktypeUrl);
return zt.Omschrijving; return zt.Omschrijving;
} }
private async Task<T> GetAsync<T>(string url) // --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------
/// <summary>Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the
/// initial status → resolve + POST the initiator rol (BSN). Sync-over-async for the same
/// reason as <see cref="ListCases"/> (see the ponytail note there) — a submit is already a
/// single request/response round trip, so no extra concurrency concern.
///
/// ponytail: no compensating transaction — if any ZGW call here throws, the aanvraag is
/// 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, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) =>
CreateZaakAsync(aanvraag, now).GetAwaiter().GetResult();
private async Task<(string Referentie, AanvraagStatusDto Status, string? ZaakUrl)> CreateZaakAsync(Aanvraag aanvraag, DateTimeOffset now)
{ {
using var req = new HttpRequestMessage(HttpMethod.Get, url); if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl))
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Mint()); throw new InvalidOperationException(
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); $"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'.");
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode(); var zaak = await zgw.PostAsync<ZgwZaak>($"{options.ZrcBaseUrl}/zaken", new CreateZaakRequest(
return (await res.Content.ReadFromJsonAsync<T>()) Zaaktype: zaaktypeUrl,
?? throw new InvalidOperationException($"ZGW GET {url} returned null body."); Bronorganisatie: options.Bronorganisatie,
VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie,
Startdatum: DateOnly.FromDateTime(now.UtcDateTime),
Identificatie: aanvraag.Referentie
?? throw new InvalidOperationException("Aanvraag has no Referentie yet — submit it locally first.")));
var statustypeUrl = await FirstStatustypeUrlAsync(zaaktypeUrl);
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen",
new CreateStatusRequest(zaak.Url, statustypeUrl, now));
var roltypeUrl = await FirstInitiatorRoltypeUrlAsync(zaaktypeUrl);
await zgw.PostAsync<JsonElement>($"{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), zaak.Url);
}
// ponytail: takes the first statustype (lowest volgnummer) / the first "initiator" roltype
// Catalogi returns for the zaaktype, rather than a fully-configured per-type mapping like
// ZaaktypeUrls — good enough while a zaaktype has exactly one initial status and one
// initiator role (the normal case); add per-type config if that ever stops holding.
private async Task<string> FirstStatustypeUrlAsync(string zaaktypeUrl)
{
var page = await zgw.GetAsync<ZgwPage<Statustype>>(
$"{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}.");
return first.Url;
}
private async Task<string> FirstInitiatorRoltypeUrlAsync(string zaaktypeUrl)
{
var page = await zgw.GetAsync<ZgwPage<Roltype>>(
$"{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 sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving); private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving);
private sealed record Statustype(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("volgnummer")] int Volgnummer);
private sealed record Roltype([property: JsonPropertyName("url")] string Url);
private sealed record CreateZaakRequest(
[property: JsonPropertyName("zaaktype")] string Zaaktype,
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
[property: JsonPropertyName("startdatum")] DateOnly Startdatum,
[property: JsonPropertyName("identificatie")] string Identificatie);
private sealed record CreateStatusRequest(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("statustype")] string Statustype,
[property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet);
private sealed record CreateRolRequest(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("betrokkeneType")] string BetrokkeneType,
[property: JsonPropertyName("roltype")] string Roltype,
[property: JsonPropertyName("roltoelichting")] string Roltoelichting,
[property: JsonPropertyName("betrokkeneIdentificatie")] BetrokkeneIdentificatie BetrokkeneIdentificatie);
private sealed record BetrokkeneIdentificatie([property: JsonPropertyName("inpBsn")] string InpBsn);
} }
@@ -0,0 +1,39 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
namespace BigRegister.Api.Zgw;
/// <summary>
/// Shared GET/POST-with-Bearer-JWT plumbing for the ZGW source classes. Factored out of
/// <see cref="OpenZaakZaakSource"/> once <c>OpenZaakDocumentSource</c> (WP-51) needed the
/// identical auth + JSON + error-handling boilerplate — every ZGW call mints a fresh token
/// (<see cref="ZgwTokenProvider"/>) and expects/returns JSON.
/// </summary>
internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
{
public async Task<T> GetAsync<T>(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<T>())
?? throw new InvalidOperationException($"ZGW GET {url} returned null body.");
}
public async Task<T> PostAsync<T>(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<T>())
?? 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"));
}
}
+24 -3
View File
@@ -6,9 +6,10 @@ namespace BigRegister.Api.Zgw;
/// SQLite store; set <c>Zgw:Enabled=true</c> (plus the URLs + credentials) to source cases /// SQLite store; set <c>Zgw:Enabled=true</c> (plus the URLs + credentials) to source cases
/// from a real OpenZaak. /// from a real OpenZaak.
/// ///
/// The ZGW standard is FIVE separate services, each its own base URL — Slice 1 only needs /// The ZGW standard is FIVE separate services, each its own base URL — slice 1 (WP-49) only
/// the Zaken API (ZRC) and, to resolve human labels for a zaaktype, the Catalogi API (ZTC). /// needed the Zaken API (ZRC) and, to resolve human labels for a zaaktype, the Catalogi API
/// The others (DRC/BRC/NRC) arrive with later slices (WP-51/52). /// (ZTC). WP-50 (create-zaak) stayed on those two; WP-51 adds the Documenten API (DRC).
/// BRC/NRC arrive with later slices (WP-52+).
/// </summary> /// </summary>
public sealed class ZgwOptions public sealed class ZgwOptions
{ {
@@ -31,4 +32,24 @@ public sealed class ZgwOptions
/// <summary>Human-readable end-user name for the audit trail (JWT <c>user_representation</c>).</summary> /// <summary>Human-readable end-user name for the audit trail (JWT <c>user_representation</c>).</summary>
public string UserRepresentation { get; init; } = "BIG-register BFF"; public string UserRepresentation { get; init; } = "BIG-register BFF";
/// <summary>Aanvraag <c>Type</c> (registratie/herregistratie/intake) → zaaktype URL (Catalogi),
/// so create-zaak (WP-50) knows which zaaktype to open per wizard. OpenZaak validates the URL
/// by fetching it, so an unconfigured or wrong entry fails loudly at create time.</summary>
public Dictionary<string, string> ZaaktypeUrls { get; init; } = new();
/// <summary>RSIN of the organisation registering the zaak (<c>bronorganisatie</c>, WP-50).</summary>
public string Bronorganisatie { get; init; } = "";
/// <summary>RSIN of the organisation responsible for the zaak (<c>verantwoordelijkeOrganisatie</c>,
/// WP-50) — usually the same RSIN as <see cref="Bronorganisatie"/>.</summary>
public string VerantwoordelijkeOrganisatie { get; init; } = "";
/// <summary>Documenten API (DRC) base URL, e.g. <c>https://open-zaak.example/documenten/api/v1</c> (WP-51).</summary>
public string DrcBaseUrl { get; init; } = "";
/// <summary>Upload <c>CategoryId</c> (diploma/identiteit/taalvaardigheid/...) → informatieobjecttype
/// URL (Catalogi), so create-document (WP-51) knows which type to register per category —
/// the document analogue of <see cref="ZaaktypeUrls"/>.</summary>
public Dictionary<string, string> InformatieobjecttypeUrls { get; init; } = new();
} }
@@ -55,4 +55,9 @@ public static class ZgwZaakMapper
// becomes midnight UTC so the FE's date parsing sees the same format either backend. // becomes midnight UTC so the FE's date parsing sees the same format either backend.
private static string Iso(DateOnly d) => private static string Iso(DateOnly d) =>
d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("o"); d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("o");
/// <summary>Status for a zaak that was JUST created (WP-50) — always the open/InBehandeling
/// coarse status (no einddatum yet), same convention as <see cref="ToSummaryDto"/>.</summary>
public static AanvraagStatusDto ToCreatedStatusDto(string identificatie) =>
new("InBehandeling", Referentie: identificatie, Manual: true);
} }
+9 -2
View File
@@ -6,7 +6,7 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"_Zgw": "WP-49: set Enabled=true + the URLs/credentials to source cases from a real OpenZaak. Off = local SQLite store (offline POC default).", "_Zgw": "WP-49/50: set Enabled=true + the URLs/credentials/RSINs/zaaktype map to source + create cases against a real OpenZaak. Off = local SQLite store (offline POC default).",
"Zgw": { "Zgw": {
"Enabled": false, "Enabled": false,
"ZrcBaseUrl": "", "ZrcBaseUrl": "",
@@ -14,6 +14,13 @@
"ClientId": "", "ClientId": "",
"Secret": "", "Secret": "",
"UserId": "big-register-bff", "UserId": "big-register-bff",
"UserRepresentation": "BIG-register BFF" "UserRepresentation": "BIG-register BFF",
"Bronorganisatie": "",
"VerantwoordelijkeOrganisatie": "",
"ZaaktypeUrls": {
"registratie": "",
"herregistratie": "",
"intake": ""
}
} }
} }
@@ -0,0 +1,108 @@
using BigRegister.Api.Data;
using BigRegister.Api.Zgw;
namespace BigRegister.Tests;
/// <summary>
/// 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.
/// </summary>
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<InvalidOperationException>(() =>
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);
}
}
@@ -1,5 +1,4 @@
using System.Net; using BigRegister.Api.Data;
using System.Text;
using BigRegister.Api.Zgw; using BigRegister.Api.Zgw;
namespace BigRegister.Tests; namespace BigRegister.Tests;
@@ -33,7 +32,7 @@ public class OpenZaakZaakSourceTests
[Fact] [Fact]
public void Follows_pagination_caches_zaaktype_and_sends_bearer_token() 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" => Page1,
_ when url == $"{ZrcBase}/zaken?page=2" => Page2, _ when url == $"{ZrcBase}/zaken?page=2" => Page2,
@@ -59,20 +58,82 @@ public class OpenZaakZaakSourceTests
Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s)); Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s));
} }
private sealed class StubHandler(Func<string, string> respond) : HttpMessageHandler [Fact]
public void CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back()
{ {
public List<string> Requests { get; } = new(); const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie";
public List<string?> AuthSchemes { get; } = new(); var handler = new ZgwStubHandler(url => url switch
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{ {
var url = request.RequestUri!.ToString(); _ when url == $"{ZrcBase}/zaken" => $$"""
Requests.Add(url); { "url": "{{ZrcBase}}/zaken/uuid-new", "identificatie": "BIG-2026-000123",
AuthSchemes.Add(request.Headers.Authorization?.Scheme); "zaaktype": "{{zaaktypeUrl}}", "startdatum": "2026-07-28",
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) "einddatum": null, "registratiedatum": "2026-07-28" }
{ """,
Content = new StringContent(respond(url), Encoding.UTF8, "application/json"), _ when url.StartsWith($"{ZtBase}/statustypen") => """
{ "count": 1, "next": null,
"results": [ { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 } ] }
""",
_ when url.StartsWith($"{ZtBase}/roltypen") => """
{ "count": 1, "next": null,
"results": [ { "url": "https://oz.example/catalogi/api/v1/roltypen/rt-initiator" } ] }
""",
_ when url == $"{ZrcBase}/statussen" => "{}",
_ when url == $"{ZrcBase}/rollen" => "{}",
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
}); });
var options = new ZgwOptions
{
ZrcBaseUrl = ZrcBase,
ZtcBaseUrl = ZtBase,
ClientId = "c",
Secret = "s",
Bronorganisatie = "123443210",
VerantwoordelijkeOrganisatie = "123443210",
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
};
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag
{
Id = "a1",
Type = "registratie",
Owner = "111222333",
Referentie = "BIG-2026-000123",
};
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);
Assert.Equal($"{ZrcBase}/zaken/uuid-new", zaakUrl);
// Zaak: mapped zaaktype + configured RSINs + the local reference as identificatie.
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 = 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 = handler.BodyOf($"{ZrcBase}/rollen");
Assert.Contains($"{ZrcBase}/zaken/uuid-new", rolBody);
Assert.Contains("roltypen/rt-initiator", rolBody);
Assert.Contains("111222333", rolBody);
} }
[Fact]
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 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<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow));
} }
} }
@@ -0,0 +1,32 @@
using System.Net;
using System.Text;
namespace BigRegister.Tests;
/// <summary>
/// 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.
/// </summary>
internal sealed class ZgwStubHandler(Func<string, string> respond) : HttpMessageHandler
{
public List<string> Requests { get; } = new();
public List<string?> AuthSchemes { get; } = new();
public List<string> Bodies { get; } = new();
public string BodyOf(string url) => Bodies[Requests.LastIndexOf(url)];
protected override Task<HttpResponseMessage> 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"),
});
}
}
+2 -2
View File
@@ -100,8 +100,8 @@ for its existing violations, so every WP ends green.
| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done | | [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done |
| [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done | | [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done |
| [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done | | [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done |
| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | todo | | [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done |
| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | todo | | [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done |
| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | todo | | [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | todo |
| [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | todo | | [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | todo |
| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | todo | | [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | todo |
@@ -1,6 +1,6 @@
# WP-50 — OpenZaak create-zaak (first write slice) # WP-50 — OpenZaak create-zaak (first write slice)
Status: todo Status: done (de3bff0)
Phase: 9 — OpenZaak / ZGW integration Phase: 9 — OpenZaak / ZGW integration
## Why ## Why
@@ -1,6 +1,6 @@
# WP-51 — OpenZaak Documenten (DRC) upload + link # WP-51 — OpenZaak Documenten (DRC) upload + link
Status: todo Status: done (5807937)
Phase: 9 — OpenZaak / ZGW integration Phase: 9 — OpenZaak / ZGW integration
## Why ## Why
@@ -64,6 +64,8 @@ up front — the migration stance ADR-0001 already prescribes.
- **Shipped with this ADR (WP-49):** `IZaakSource` + `LocalZaakSource` (default) + - **Shipped with this ADR (WP-49):** `IZaakSource` + `LocalZaakSource` (default) +
`OpenZaakZaakSource` (config-gated), the `Zgw/` client (`ZgwOptions`, `ZgwTokenProvider`, `OpenZaakZaakSource` (config-gated), the `Zgw/` client (`ZgwOptions`, `ZgwTokenProvider`,
`ZgwZaakMapper`), and the reference guide [openzaak-integration.md](../openzaak-integration.md). `ZgwZaakMapper`), and the reference guide [openzaak-integration.md](../openzaak-integration.md).
- **Deferred:** real inbound OIDC/JWT auth (still header-stubbed), create-zaak (WP-50), - **Also shipped (WP-50):** `IZaakSource.CreateZaak` — the first write. Submitting an aanvraag
Documenten/DRC upload + link (WP-51), Notificaties/NRC webhooks (WP-52), adding OpenZaak to now also creates a Zaak + Status + Rol in OpenZaak when `Zgw:Enabled=true`, routed through the
docker-compose. existing submit endpoint with zero DTO change (same seam, same anti-corruption boundary).
- **Deferred:** real inbound OIDC/JWT auth (still header-stubbed), Documenten/DRC upload + link
(WP-51), Notificaties/NRC webhooks (WP-52), adding OpenZaak to docker-compose.
+120 -32
View File
@@ -1,9 +1,12 @@
# OpenZaak / ZGW integration — how the BFF connects (& how to extend) # OpenZaak / ZGW integration — how the BFF connects (& how to extend)
How the BFF sources cases from a real **OpenZaak** (ZGW APIs) while the frontend stays How the BFF sources (and now creates) cases, and uploads/links documents, against a real
unchanged. For the _why_, see [ADR-0005](architecture/0005-openzaak-behind-bff.md); this page **OpenZaak** (ZGW APIs) while the frontend stays unchanged. For the _why_, see
is _how the seam is built and how to add the next slice_. Built in [ADR-0005](architecture/0005-openzaak-behind-bff.md); this page is _how the seam is built and
[WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (read-only zaken). 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 ## The one rule: OpenZaak sits behind the BFF, never in the browser
@@ -14,28 +17,97 @@ with **zero frontend change and no api-client drift**.
## The seam (data source by config) ## The seam (data source by config)
- `Data/IZaakSource.cs` — the cases READ interface. Returns the existing - `Data/IZaakSource.cs` — the cases READ + (WP-50) WRITE interface: `ListCases` and
`ApplicationSummaryDto`, so each implementation owns its own mapping. `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` - `Data/LocalZaakSource.cs` — **default**; reads the local SQLite `ApplicationStore`
(offline, unchanged behaviour). (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`. - `Zgw/OpenZaakZaakSource.cs` — the OpenZaak client; selected only when `Zgw:Enabled=true`.
- Wiring (`Program.cs`): `if (Zgw:Enabled) AddHttpClient<IZaakSource, OpenZaakZaakSource>() `CreateZaak` posts a Zaak, then a Status, then a Rol (see below).
else AddSingleton<IZaakSource, LocalZaakSource>()`. The `/admin/cases` endpoint resolves - `Data/IDocumentSource.cs` — the documents seam (WP-51), sibling of `IZaakSource`: `Upload`
`IZaakSource` from DI — routes + DTOs untouched. 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
`POST /applications/{id}/submit` already persists the aanvraag locally (`ApplicationStore.Submit`
— 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, 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`
(RSIN) from config, `identificatie` set to the **same** reference `ApplicationStore.Submit`
already generated — so the human-readable reference matches in both places, not two
independently-generated ones.
2. **POST status** (`{ZrcBaseUrl}/statussen`) — `statustype` resolved via a Catalogi GET
(`statustypen?zaaktype=...`, lowest `volgnummer`); marks the zaak as freshly opened.
3. **POST rol** (`{ZrcBaseUrl}/rollen`) — `roltype` resolved via a Catalogi GET
(`roltypen?zaaktype=...&omschrijvingGeneriek=initiator`); `betrokkeneIdentificatie.inpBsn`
set to the aanvraag's owner (BSN) — the current stand-in for real identity (WP-53).
The created zaak's `identificatie` becomes the returned `Referentie`; its status maps to the
same coarse `InBehandeling` shape `ZgwZaakMapper` already uses for a freshly-opened zaak
(`ZgwZaakMapper.ToCreatedStatusDto`).
ponytail shortcuts, marked at the call sites: (a) "first statustype/roltype Catalogi returns"
rather than a fully-configured per-type map — fine while a zaaktype has exactly one initial
status and initiator role; (b) no compensating transaction — if any ZGW call throws, the
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/`) ## The ZGW client (`backend/src/BigRegister.Api/Zgw/`)
- `ZgwOptions.cs` — bound from the `Zgw` appsettings section: `Enabled`, per-service base URLs - `ZgwOptions.cs` — bound from the `Zgw` appsettings section: `Enabled`, per-service base URLs
(`ZrcBaseUrl`, `ZtcBaseUrl`), `ClientId`, `Secret`, `UserId`, `UserRepresentation`. The five (`ZrcBaseUrl`, `ZtcBaseUrl`, `DrcBaseUrl`), `ClientId`, `Secret`, `UserId`,
ZGW APIs are separate base URLs; slice 1 needs only Zaken (ZRC) + Catalogi (ZTC). `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`/ - `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 `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). 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 - `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 where **URL identity** becomes the trailing uuid and the **zaaktype URL** is resolved to a
human label (the cross-service join). human label (the cross-service join).
- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves + - `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves +
caches zaaktype labels, attaches `Authorization: Bearer <jwt>`. caches zaaktype labels, attaches `Authorization: Bearer <jwt>`.
- `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern.
## The five ZGW APIs (context for later slices) ## The five ZGW APIs (context for later slices)
@@ -49,22 +121,24 @@ else AddSingleton<IZaakSource, LocalZaakSource>()`. The `/admin/cases` endpoint
## How to add the next slice ## How to add the next slice
1. **Read** — extend `IZaakSource` (or add a sibling interface, e.g. `IDocumentSource`) with 1. **Read** — extend `IZaakSource` (or add a sibling interface, like `IDocumentSource`, WP-51)
the new operation; implement it on both `LocalZaakSource` and the OpenZaak source. Keep the with the new operation; implement it on both the local store and the OpenZaak source. Keep
return type the existing DTO so the FE never changes. 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 2. **Write** (create-zaak WP-50, DRC upload/link WP-51) — a create/upload needs a type URL
validates it by fetching), then usually a follow-up `status` + `rol`. Route it through the from Catalogi (OpenZaak validates it by fetching), then usually a follow-up call (`status` +
existing submit/mutation seam. `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 3. **Enforce server-side** for anything the FE gates — a config value the FE echoes is never
the authority (ADR-0001). the authority (ADR-0001).
## Coupling ## Coupling
Low and one-directional. Consumer coupling is near zero — `IZaakSource` is injected at one Low and one-directional. Consumer coupling is near zero — `IZaakSource`/`IDocumentSource` are
endpoint, and the FE is fully decoupled by the DTO. The producer side is contained in `Zgw/`: each injected at one endpoint, and the FE is fully decoupled by the DTO. The producer side is
add a slice by adding a source method + a mapper case, not by touching the FE or the contract. contained in `Zgw/`: add a slice by adding a source method + a mapper case, not by touching the
Watch the **sync-over-async** `ponytail:` note in `OpenZaakZaakSource` — make the cases read FE or the contract. Watch the **sync-over-async** `ponytail:` note in `OpenZaakZaakSource` (and
path async if OpenZaak becomes the default. its `OpenZaakDocumentSource` sibling) — make the read/write paths async if OpenZaak becomes the
default.
## Config ## Config
@@ -74,8 +148,21 @@ path async if OpenZaak becomes the default.
"Enabled": true, "Enabled": true,
"ZrcBaseUrl": "https://open-zaak.example/zaken/api/v1", "ZrcBaseUrl": "https://open-zaak.example/zaken/api/v1",
"ZtcBaseUrl": "https://open-zaak.example/catalogi/api/v1", "ZtcBaseUrl": "https://open-zaak.example/catalogi/api/v1",
"DrcBaseUrl": "https://open-zaak.example/documenten/api/v1",
"ClientId": "big-register", "Secret": "<from a secret store>", "ClientId": "big-register", "Secret": "<from a secret store>",
"UserId": "<session user>", "UserRepresentation": "<session name>" "UserId": "<session user>", "UserRepresentation": "<session name>",
// WP-50 (create-zaak): RSINs + the aanvraag-type → zaaktype URL map.
"Bronorganisatie": "<RSIN>", "VerantwoordelijkeOrganisatie": "<RSIN>",
"ZaaktypeUrls": {
"registratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>",
"herregistratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>",
"intake": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>"
},
// WP-51 (Documenten): upload category → informatieobjecttype URL map.
"InformatieobjecttypeUrls": {
"identiteit": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/<uuid>",
"diploma": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/<uuid>"
}
} }
``` ```
@@ -111,17 +198,18 @@ Principles this demonstrates:
comment in `ZgwZaakMapper` show where the ACL is deliberately thin — an ACL need not be 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. complete on day one, but its shortcuts should be visible.
Caveat: today only the cases **read** path has a source interface (`IZaakSource`). Other BFF Caveat: `IZaakSource` covers the cases **read + create** path (WP-49/50) and `IDocumentSource`
endpoints still read `SeedData`/static stores directly — ACL-ready (the DTO seam exists) but not covers **upload + zaak-link** (WP-51). Other BFF endpoints still read `SeedData`/static stores
yet swappable. That is the WP-50/51/52 roadmap, plus the two cross-cutting WPs the arc needs for directly — ACL-ready (the DTO seam exists) but not yet swappable. That is the WP-52 roadmap
production: **WP-53** (a real per-request identity seam + citizen-scoping — today the owner/BSN (notificaties), plus the two cross-cutting WPs the arc needs for production: **WP-53** (a real
is stubbed) and **WP-54** (a docker OpenZaak harness + opt-in integration test — today everything per-request identity seam + citizen-scoping — today the owner/BSN is stubbed) and **WP-54** (a
is fixture/mock-tested against no live instance). docker OpenZaak harness + opt-in integration test — today everything is fixture/mock-tested
against no live instance).
## See also ## See also
- [ADR-0005 — OpenZaak behind the BFF](architecture/0005-openzaak-behind-bff.md) — the decision. - [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. - [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). - [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` — the seam. - `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). - [ZGW standard (VNG)](https://vng-realisatie.github.io/gemma-zaken/) · [OpenZaak auth docs](https://open-zaak.readthedocs.io/en/stable/client-development/authentication.html).