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>
This commit is contained in:
eho
2026-07-29 20:54:31 +02:00
co-authored by Claude Sonnet 5
parent 3671684528
commit 5807937229
18 changed files with 836 additions and 116 deletions
@@ -26,6 +26,13 @@ public sealed class Aanvraag
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { 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>
@@ -172,4 +179,18 @@ public static class ApplicationStore
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)
{
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
@@ -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).
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);
}
@@ -22,11 +22,13 @@ public interface IZaakSource
/// <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 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 (<see cref="ApplicationStore.SetZaakUrl"/>, WP-51 needs it
/// to later link documents to this zaak).
/// </summary>
(string Referentie, AanvraagStatusDto Status) CreateZaak(Aanvraag aanvraag, DateTimeOffset now);
(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);
}
@@ -15,6 +15,6 @@ public sealed class LocalZaakSource : IZaakSource
/// <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) 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);
}
@@ -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")
.HasColumnType("TEXT");
b.Property<string>("ZaakUrl")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Applications");
@@ -235,6 +238,9 @@ namespace BigRegister.Api.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DrcUrl")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");