Compare commits

...

6 Commits

Author SHA1 Message Date
f2d79919c4 fix(e2e): poll the register by reference, not a shared INGEDIEND cell (refs #78)
All checks were successful
CI / lint (pull_request) Successful in 1m23s
CI / build (pull_request) Successful in 1m17s
CI / unit (pull_request) Successful in 1m36s
CI / frontend (pull_request) Successful in 2m30s
CI / mutation (pull_request) Successful in 5m40s
CI / verify-stack (pull_request) Successful in 7m16s
The verify stack is shared: earlier verify-projection/verify-domain checks leave
INGEDIEND rows in the register before the e2e submits. A status-only poll
short-circuited on a stale row, then the reference assertion ran against a
not-yet-reloaded page and timed out. Poll (with reload) on this submission's
reference cell instead — stronger, and it directly proves the #78 correlation.
Scope the INGEDIEND/INGESCHREVEN status checks to that reference row too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:36:24 +02:00
78e6c1d3d2 docs(architecture): ADR-0012 reference correlation + wire ACL for the subscriber (refs #78)
Some checks failed
CI / lint (pull_request) Successful in 4m33s
CI / build (pull_request) Successful in 1m17s
CI / unit (pull_request) Successful in 1m34s
CI / frontend (pull_request) Successful in 3m8s
CI / mutation (pull_request) Successful in 6m36s
CI / verify-stack (pull_request) Failing after 10m17s
Records the domain->ACL (write) and event-subscriber->ACL (read) boundary for the
single citizen reference, wires Acl__BaseUrl into the subscriber in compose, extends
the e2e happy path to assert confirmation reference == register reference, and adds
the demo note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:47:03 +02:00
44e6f81f81 feat(portal-openbaar): surface the citizen reference in the public register (refs #78)
The BFF's public view exposes id/status/reference (never bsn/naam) and searches by
id or reference; the openbaar register's Referentie column and search now show the
reference the citizen saw on submit. api-client + openapi.json regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:46:55 +02:00
566fc9bcea feat(event-subscriber): enrich the projection with the zaak reference via the ACL (refs #78)
On each notification the subscriber reads the zaak's reference (identificatie)
through the ACL — the only code allowed to talk to ZGW (§8.1) — and persists it on
the register_projection row and in the processed_notifications replay log. Storing
it in the log keeps rebuild log-only (ADR-0008): no ACL/ZGW access on rebuild.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:46:48 +02:00
7b841d6e62 feat(domain): pass the registrationId to the ACL as the zaak reference (refs #78)
OpenZaakWorker forwards registration.Id to IAclClient.OpenZaakAsync so the zaak's
identificatie equals the reference the citizen sees on the submit confirmation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:46:40 +02:00
ffbd8072b2 feat(acl): set and read the zaak identificatie as the citizen reference (refs #78)
The ACL now writes the domain registrationId as the zaak's identificatie on
POST /zaken, and exposes GET-through POST /zaken/reference to read a zaak's
identificatie back. Only the ACL touches ZGW (§8.1); the reference is the single
value shown on both the self-service confirmation and the openbaar register.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:46:32 +02:00
50 changed files with 736 additions and 78 deletions

View File

@@ -43,7 +43,7 @@
<tbody> <tbody>
@for (entry of entries(); track entry.id) { @for (entry of entries(); track entry.id) {
<tr> <tr>
<td>{{ entry.id }}</td> <td>{{ entry.reference }}</td>
<td>{{ entry.status }}</td> <td>{{ entry.status }}</td>
</tr> </tr>
} }

View File

@@ -5,8 +5,8 @@ import { axe } from 'vitest-axe';
import { RegisterPage } from './register-page'; import { RegisterPage } from './register-page';
const sample: OpenbaarEntry[] = [ const sample: OpenbaarEntry[] = [
{ id: 'zaak-abc', status: 'INGEDIEND' }, { id: 'zaak-abc', status: 'INGEDIEND', reference: 'REG-abc' },
{ id: 'zaak-def', status: 'INGESCHREVEN' }, { id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' },
]; ];
function providers(get = vi.fn().mockReturnValue(of(sample))) { function providers(get = vi.fn().mockReturnValue(of(sample))) {
@@ -22,9 +22,11 @@ describe('RegisterPage', () => {
await render(RegisterPage, { providers: providers(get).providers }); await render(RegisterPage, { providers: providers(get).providers });
expect(get).toHaveBeenCalled(); expect(get).toHaveBeenCalled();
expect(await screen.findByText(/zaak-abc/)).toBeTruthy(); // The Referentie column shows the citizen's reference (matches the submit confirmation, #78),
// not the internal zaak id.
expect(await screen.findByText(/REG-abc/)).toBeTruthy();
expect(screen.getByText(/INGEDIEND/)).toBeTruthy(); expect(screen.getByText(/INGEDIEND/)).toBeTruthy();
expect(screen.getByText(/zaak-def/)).toBeTruthy(); expect(screen.getByText(/REG-def/)).toBeTruthy();
}); });
it('searches by the entered term', async () => { it('searches by the entered term', async () => {

View File

@@ -0,0 +1,104 @@
# ADR-0012: One citizen-facing reference across self-service and the openbaar register
- **Status:** Accepted
- **Date:** 2026-07-14
- **Deciders:** Respellion engineering
- **Relates to:** #78 (adr-proposal); builds on ADR-0008 (read projection), ADR-0001 (loose coupling), ADR-0009 (external-task worker / zaak creation)
## Context
A citizen submits through the self-service portal and is shown a confirmation with a
**reference** so they can find their registration back in the public register. But the two
sides showed **different identifiers**:
- The self-service confirmation shows the **domain `registrationId`** — a GUID minted by the
domain aggregate (`RegistrationId.New()`) when the registration is created, before any zaak
exists.
- The openbaar register showed the **zaak id** — the UUID from the NRC `hoofdObject` URL,
assigned by OpenZaak when the ACL opens the zaak.
These never match, so the reference on the confirmation was useless for looking the entry up.
The two identifiers live on opposite sides of the ACL boundary and are generated by different
systems at different times, so there is no way to reconcile them after the fact without a
correlating value carried across the boundary.
The NRC notification the Event Subscriber consumes carries only the zaak URL plus the fixed
`kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`) — **not** the
`registrationId`, the bsn, or the `identificatie`. ADR-0008 already recorded that filling any
such field means reading the zaak **through the ACL** (§8.1) and deferred it as a follow-up.
This is that follow-up, scoped to the one field the citizen actually needs.
## Decision
**Use the domain `registrationId` as the zaak's `identificatie`, and surface that single value
as the citizen-facing `reference` on both portals. The Event Subscriber enriches the projection
with the reference by reading the zaak through the ACL, and stores it in the replay log so
rebuild stays log-only.**
Concretely, following the request path:
1. **Domain → ACL (write).** When the OpenZaak worker opens a zaak, it passes
`registration.Id` to the ACL (`IAclClient.OpenZaakAsync(bsn, reference, …)`). The ACL sets
it as the zaak's `identificatie` on `POST /zaken`. OpenZaak's `identificatie` is unique per
`bronorganisatie` and ≤ 40 chars — a GUID string fits. The ACL remains the only code that
constructs ZGW payloads (§8.1); the domain never sees a ZGW URL.
2. **Event Subscriber → ACL (read).** On a notification, the subscriber asks the ACL for the
zaak's reference via a new `POST /zaken/reference` endpoint (`{ zaakUrl } → { reference }`),
which reads the zaak's `identificatie` through the ACL's OpenZaak gateway. The subscriber
still never talks to ZGW itself (§8.1) — it depends only on the ACL, over HTTP.
3. **Projection + replay log.** The reference is written both to the `register_projection` row
**and** to the `processed_notifications` replay log (a new nullable `reference` column on
each). Storing it in the log is what keeps ADR-0008's "**rebuild replays the log, not
OpenZaak**" invariant true: `POST /admin/rebuild` reproduces the reference from the log
without re-reading the ACL.
4. **BFF + openbaar.** The public view (`OpenbaarProjection.PublicView`) exposes
`id`, `status`, and `reference` (never bsn/naam), and the openbaar search matches on either
`id` or `reference`. The openbaar register's "Referentie" column now renders `reference`.
The end-to-end guarantee is asserted in the Playwright walking-skeleton: the reference captured
from the submit confirmation must appear as a cell in the public register.
### Why HTTP to the ACL, not the ACL as a library
ADR-0008 floated "extend the ACL with a zaak-read operation, consumed as a library." We instead
call the ACL **over HTTP**, consistent with every other cross-service hop in this system
(portals→BFF, domain→ACL). Sharing the ACL as a library would couple the subscriber to the
ACL's infrastructure assembly and its ZGW client configuration, defeating the anti-corruption
boundary. The HTTP endpoint keeps the ACL the single owner of ZGW access and its config.
## Consequences
**Positive**
- One reference, end to end: the citizen's confirmation value is exactly what the public
register shows and searches by.
- §8.1 stays intact — only the ACL reads or writes ZGW; the subscriber depends on the ACL, not
OpenZaak.
- Rebuild stays log-only (ADR-0008): the reference is replayed from `processed_notifications`,
so `/admin/rebuild` needs no ACL/ZGW access.
- The column additions are nullable and additive; older rows without a reference are tolerated.
**Negative / costs**
- A new coupling: the Event Subscriber now depends on the ACL being reachable
(`Acl__BaseUrl`, compose `depends_on: acl`). A registration whose reference read fails will
need the notification redelivered (NRC already redelivers; the projection upsert is
idempotent).
- One extra HTTP hop per notification (subscriber→ACL→OpenZaak) on the projection path. Bounded:
one small GET per zaak, off the citizen's request path.
- `identificatie` now carries semantic meaning (it equals the `registrationId`). If OpenZaak
were ever configured to auto-generate `identificatie`, the correlation would break; the ACL
setting it explicitly is now load-bearing.
## Alternatives considered
- **Carry the `registrationId` in the notification** — rejected: NRC `kenmerken` are fixed and
the notification content is not ours to extend; it would also couple the projection to a
bespoke notification shape.
- **Show the zaak id on the confirmation instead** — rejected: the zaak does not exist yet when
the confirmation is returned (the worker opens it asynchronously, ADR-0009), so the domain has
no zaak id to show at submit time.
- **Store only on the projection row, re-read the ACL on rebuild** — rejected: it would make
rebuild depend on the ACL/ZGW, breaking ADR-0008's log-only rebuild invariant.
- **Reconcile the two ids in a lookup table** — rejected: adds write-only state and a second
source of truth for a value that can simply be the same on both sides.

View File

@@ -227,3 +227,24 @@ curl -fsS http://localhost:8140/openbaar/register | jq
> **End of walking skeleton** (S-09 + S-09b): submit → process → projection → public visibility, from > **End of walking skeleton** (S-09 + S-09b): submit → process → projection → public visibility, from
> INGEDIEND through approval to INGESCHREVEN. The subscriber takes any post-creation status-set as the > INGEDIEND through approval to INGESCHREVEN. The subscriber takes any post-creation status-set as the
> approval (ADR-0011) — a walking-skeleton assumption that tightens when more transitions arrive (S-12+). > approval (ADR-0011) — a walking-skeleton assumption that tightens when more transitions arrive (S-12+).
## #78 — One reference across both portals (ADR-0012)
Before this change the self-service confirmation and the openbaar register showed **different**
identifiers, so a citizen could not look their registration back up. Now both show the same
**reference**: the domain `registrationId` is set as the zaak's `identificatie` by the ACL, and the
Event Subscriber enriches the projection with it by reading the zaak through the ACL (§8.1) — storing
it in the replay log so rebuild stays log-only (ADR-0008).
**The path:** domain passes `registrationId` → ACL sets it as `zaak.identificatie` → NRC →
Event Subscriber asks the ACL for the reference → projection row + replay log → openbaar register.
```bash
# Submit as in S-09 and note the reference on the confirmation, then find it in the public register:
ref="<registration-reference-from-the-confirmation>"
curl -fsS "http://localhost:8140/openbaar/register?q=$ref" | jq
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND", "reference": "<same-ref-as-confirmation>" } ]
```
> The openbaar register's "Referentie" column and its search now use this reference — the exact value
> the citizen saw on submit. Asserted end-to-end by the Playwright happy path.

View File

@@ -396,6 +396,9 @@ services:
image: register-referentie/event-subscriber:dev image: register-referentie/event-subscriber:dev
environment: environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
# The subscriber enriches the projection with each zaak's reference (identificatie) by asking
# the ACL — the only code allowed to read ZGW (§8.1, #78).
Acl__BaseUrl: http://acl:8080/
# The bearer Open Notificaties must present on the abonnement callback. NRC's # The bearer Open Notificaties must present on the abonnement callback. NRC's
# registration probe expects a 401 without it (ADR-0007). Dev-only token. # registration probe expects a 401 without it (ADR-0007). Dev-only token.
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications} EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
@@ -410,6 +413,8 @@ services:
depends_on: depends_on:
projection-db: projection-db:
condition: service_healthy condition: service_healthy
acl:
condition: service_healthy
networks: [cg] networks: [cg]
# The read side of the projection. Shares Projection.ReadModel, so build context is root. # The read side of the projection. Shares Projection.ReadModel, so build context is root.

View File

@@ -27,6 +27,8 @@ import {
export interface OpenbaarEntry { export interface OpenbaarEntry {
id: string; id: string;
status: string; status: string;
/** @nullable */
reference: string | null;
} }
export interface SubmitAccepted { export interface SubmitAccepted {

View File

@@ -20,7 +20,7 @@ app.MapGet("/health", () => "Healthy");
// The ACL's single operation, exposed as a service endpoint. // The ACL's single operation, exposed as a service endpoint.
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) => app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
{ {
var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn), ct); var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn, body.Reference), ct);
return Results.Ok(new { zaakUrl = zaakUrl.ToString() }); return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
}); });
@@ -32,10 +32,20 @@ app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, Cancella
return Results.NoContent(); return Results.NoContent();
}); });
// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich
// the read projection without reading ZGW itself (§8.1, #78).
app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) =>
{
var reference = await acl.GetZaakReferenceAsync(new Uri(body.ZaakUrl), ct);
return Results.Ok(new { reference });
});
app.Run(); app.Run();
public sealed record OpenZaakRequest(string Bsn); public sealed record OpenZaakRequest(string Bsn, string Reference);
public sealed record SetStatusRequest(string ZaakUrl); public sealed record SetStatusRequest(string ZaakUrl);
public sealed record ZaakReferenceRequest(string ZaakUrl);
public partial class Program; public partial class Program;

View File

@@ -13,7 +13,8 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
defaults.VerantwoordelijkeOrganisatie, defaults.VerantwoordelijkeOrganisatie,
defaults.Vertrouwelijkheidaanduiding, defaults.Vertrouwelijkheidaanduiding,
defaults.ZaaktypeUrl, defaults.ZaaktypeUrl,
clock.Today); clock.Today,
registration.Reference);
return gateway.OpenZaakAsync(request, ct); return gateway.OpenZaakAsync(request, ct);
} }
@@ -28,4 +29,12 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct); return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
} }
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
return gateway.GetZaakIdentificatieAsync(zaakUrl, ct);
}
} }

View File

@@ -1,4 +1,5 @@
namespace Acl.Application; namespace Acl.Application;
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here.</summary> /// <summary>Domain-language payload handed to the ACL. No ZGW concepts here. <see cref="Reference"/>
public sealed record DomainRegistration(string Bsn); /// is the registration's own id; the ACL records it as the zaak identificatie (adr-proposal #78).</summary>
public sealed record DomainRegistration(string Bsn, string Reference);

View File

@@ -12,4 +12,8 @@ public interface IZaakGateway
/// the catalogus and POSTs a status against the zaak, dated <paramref name="datumStatusGezet"/>. /// the catalogus and POSTs a status against the zaak, dated <paramref name="datumStatusGezet"/>.
/// </summary> /// </summary>
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default); Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
/// <summary>Read the zaak's <c>identificatie</c> — the public-safe reference the register shows.
/// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78).</summary>
Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default);
} }

View File

@@ -6,4 +6,5 @@ public sealed record ZaakRequest(
string VerantwoordelijkeOrganisatie, string VerantwoordelijkeOrganisatie,
string Vertrouwelijkheidaanduiding, string Vertrouwelijkheidaanduiding,
Uri Zaaktype, Uri Zaaktype,
DateOnly Startdatum); DateOnly Startdatum,
string Identificatie);

View File

@@ -20,7 +20,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
request.Zaaktype.ToString(), request.Zaaktype.ToString(),
request.VerantwoordelijkeOrganisatie, request.VerantwoordelijkeOrganisatie,
request.Startdatum.ToString("yyyy-MM-dd"), request.Startdatum.ToString("yyyy-MM-dd"),
request.Vertrouwelijkheidaanduiding)), request.Vertrouwelijkheidaanduiding,
request.Identificatie)),
}; };
message.Headers.Authorization = message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
@@ -61,6 +62,24 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
"Setting the zaak status", ct); "Setting the zaak status", ct);
} }
public async Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
using var message = new HttpRequestMessage(HttpMethod.Get, zaakUrl);
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
// The zaak is a geo resource; the Zaken API requires the CRS header even on GET.
message.Headers.Add("Accept-Crs", "EPSG:4326");
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, "Reading the zaak", ct);
var zaak = await response.Content.ReadFromJsonAsync<ZaakReadDto>(ct)
?? throw new InvalidOperationException("OpenZaak returned an empty zaak response");
return zaak.Identificatie;
}
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets // POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
// a Content-Length instead of a chunked body (as with zaak-create). // a Content-Length instead of a chunked body (as with zaak-create).
private async Task PostAsync(string path, object dto, string action, CancellationToken ct) private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
@@ -132,11 +151,15 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
[property: JsonPropertyName("zaaktype")] string Zaaktype, [property: JsonPropertyName("zaaktype")] string Zaaktype,
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie, [property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
[property: JsonPropertyName("startdatum")] string Startdatum, [property: JsonPropertyName("startdatum")] string Startdatum,
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding); [property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding,
[property: JsonPropertyName("identificatie")] string Identificatie);
private sealed record ZaakCreatedDto( private sealed record ZaakCreatedDto(
[property: JsonPropertyName("url")] string Url); [property: JsonPropertyName("url")] string Url);
private sealed record ZaakReadDto(
[property: JsonPropertyName("identificatie")] string Identificatie);
private sealed record StatusDto( private sealed record StatusDto(
[property: JsonPropertyName("zaak")] string Zaak, [property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("statustype")] string Statustype, [property: JsonPropertyName("statustype")] string Statustype,

View File

@@ -22,12 +22,14 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
"seed it with OZ_PUBLISH=1 (`make integration` does this)."); "seed it with OZ_PUBLISH=1 (`make integration` does this).");
var gateway = new OpenZaakGateway(stack.Http, stack.Options); var gateway = new OpenZaakGateway(stack.Http, stack.Options);
var reference = Guid.NewGuid().ToString(); // zaak identificatie must be unique per bronorganisatie
var request = new ZaakRequest( var request = new ZaakRequest(
Bronorganisatie: "517439943", Bronorganisatie: "517439943",
VerantwoordelijkeOrganisatie: "517439943", VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar", Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!, Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow)); Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
Identificatie: reference);
var zaakUrl = await gateway.OpenZaakAsync(request); var zaakUrl = await gateway.OpenZaakAsync(request);
@@ -36,11 +38,12 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(), new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(),
zaakUrl.ToString()); zaakUrl.ToString());
// ...and that zaak is really persisted with the default-filled fields. // ...and that zaak is really persisted with the default-filled fields + the reference identificatie.
var zaak = await stack.GetZaakAsync(zaakUrl); var zaak = await stack.GetZaakAsync(zaakUrl);
Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString()); Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString());
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString()); Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString()); Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
Assert.Equal(reference, zaak.GetProperty("identificatie").GetString());
} }
[Fact] [Fact]
@@ -57,7 +60,8 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
VerantwoordelijkeOrganisatie: "517439943", VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar", Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!, Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow))); Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
Identificatie: Guid.NewGuid().ToString()));
await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow)); await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));

View File

@@ -22,6 +22,14 @@ public class AclServiceTests
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet); Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
return Task.CompletedTask; return Task.CompletedTask;
} }
public Uri? ReadReferenceFor;
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
{
ReadReferenceFor = zaakUrl;
return Task.FromResult("REG-FROM-ZAAK");
}
} }
private static AclDefaults Defaults() => new() private static AclDefaults Defaults() => new()
@@ -50,7 +58,7 @@ public class AclServiceTests
}; };
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var url = await service.OpenZaakAsync(new DomainRegistration("123456782")); var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77"));
Assert.Equal(gateway.Result, url); Assert.Equal(gateway.Result, url);
var req = Assert.IsType<ZaakRequest>(gateway.Captured); var req = Assert.IsType<ZaakRequest>(gateway.Captured);
@@ -59,6 +67,8 @@ public class AclServiceTests
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype); Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum); Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
// The registration reference becomes the zaak identificatie (#78).
Assert.Equal("reg-77", req.Identificatie);
} }
[Fact] [Fact]
@@ -103,4 +113,27 @@ public class AclServiceTests
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!)); await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
Assert.Null(gateway.Approved); Assert.Null(gateway.Approved);
} }
[Fact]
public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
{
var gateway = new FakeGateway();
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
var reference = await service.GetZaakReferenceAsync(zaak);
Assert.Equal("REG-FROM-ZAAK", reference);
Assert.Equal(zaak, gateway.ReadReferenceFor);
}
[Fact]
public async Task Reading_a_null_zaak_reference_is_rejected()
{
var gateway = new FakeGateway();
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
Assert.Null(gateway.ReadReferenceFor);
}
} }

View File

@@ -22,7 +22,7 @@ public class OpenZaakGatewayTests
private static ZaakRequest SampleRequest() => new( private static ZaakRequest SampleRequest() => new(
"517439943", "517439943", "openbaar", "517439943", "517439943", "openbaar",
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4)); new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4), "REG-REF-1");
private static StubHandler Created(out RequestCapture capture) private static StubHandler Created(out RequestCapture capture)
{ {
@@ -67,6 +67,8 @@ public class OpenZaakGatewayTests
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body); Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body); Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body); Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body);
// The registration reference is set as the zaak identificatie (#78).
Assert.Contains("\"identificatie\":\"REG-REF-1\"", capture.Body);
} }
[Fact] [Fact]
@@ -357,6 +359,61 @@ public class OpenZaakGatewayTests
Assert.Equal(4, rec.Requests.Count); Assert.Equal(4, rec.Requests.Count);
} }
[Fact]
public async Task Reading_a_zaak_returns_its_identificatie_with_bearer_and_crs()
{
RequestCapture? capture = null;
var handler = new StubHandler(req =>
{
capture = new RequestCapture { Seen = req };
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new { identificatie = "REG-XYZ", url = ZaakUrl }),
});
});
var reference = await Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl));
Assert.Equal("REG-XYZ", reference);
Assert.Equal(HttpMethod.Get, capture!.Seen!.Method);
Assert.Equal(ZaakUrl, capture.Seen.RequestUri!.ToString());
Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme);
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Headers.GetValues("Accept-Crs")));
}
[Fact]
public async Task Reading_a_zaak_throws_when_openzaak_rejects_it()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("nope") }));
var ex = await Assert.ThrowsAsync<HttpRequestException>(
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
Assert.Contains("Reading the zaak", ex.Message);
}
[Fact]
public async Task Reading_a_zaak_throws_when_openzaak_returns_an_empty_body()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"),
}));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
Assert.Contains("empty zaak response", ex.Message);
}
[Fact]
public async Task Reading_a_null_zaak_is_rejected()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).GetZaakIdentificatieAsync(null!));
}
[Fact] [Fact]
public async Task Approving_rejects_a_null_zaak_or_zaaktype() public async Task Approving_rejects_a_null_zaak_or_zaaktype()
{ {

View File

@@ -6,11 +6,12 @@ namespace Bff.Api;
public sealed record SubmitAccepted(string RegistrationId, string Status); public sealed record SubmitAccepted(string RegistrationId, string Status);
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are /// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).</summary> /// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).
public sealed record ProjectionEntry(string Id, string Status, string? Bsn, string? NaamPlaceholder); /// <c>Reference</c> is the public-safe citizen reference (the zaak identificatie, #78).</summary>
public sealed record ProjectionEntry(string Id, string Status, string? Reference, string? Bsn, string? NaamPlaceholder);
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary> /// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
public sealed record OpenbaarEntry(string Id, string Status); public sealed record OpenbaarEntry(string Id, string Status, string? Reference);
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary> /// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
public interface IDomainClient public interface IDomainClient

View File

@@ -11,8 +11,10 @@ public static class OpenbaarProjection
{ {
var filtered = string.IsNullOrWhiteSpace(q) var filtered = string.IsNullOrWhiteSpace(q)
? entries ? entries
: entries.Where(e => e.Id.Contains(q, StringComparison.OrdinalIgnoreCase)); : entries.Where(e =>
e.Id.Contains(q, StringComparison.OrdinalIgnoreCase) ||
(e.Reference?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false));
return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status))]; return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status, e.Reference))];
} }
} }

View File

@@ -10,7 +10,7 @@ public class OpenbaarEndpointTests
public async Task Serves_public_safe_rows_anonymously() public async Task Serves_public_safe_rows_anonymously()
{ {
using var factory = new BffFactory(); using var factory = new BffFactory();
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "123456782", "Jan")); factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "REG-abc", "123456782", "Jan"));
// No Authorization header — the openbaar register is a public lookup (ADR-0010/S-09). // No Authorization header — the openbaar register is a public lookup (ADR-0010/S-09).
var response = await factory.CreateClient().GetAsync("/openbaar/register"); var response = await factory.CreateClient().GetAsync("/openbaar/register");
@@ -19,7 +19,9 @@ public class OpenbaarEndpointTests
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
Assert.Contains("abc-111", body); Assert.Contains("abc-111", body);
Assert.Contains("INGEDIEND", body); Assert.Contains("INGEDIEND", body);
// The bsn must never appear in a public response. // The public reference is surfaced (matches the submit confirmation, #78)...
Assert.Contains("REG-abc", body);
// ...but the bsn must never appear in a public response.
Assert.DoesNotContain("123456782", body); Assert.DoesNotContain("123456782", body);
} }
@@ -27,8 +29,8 @@ public class OpenbaarEndpointTests
public async Task Filters_by_the_query_parameter() public async Task Filters_by_the_query_parameter()
{ {
using var factory = new BffFactory(); using var factory = new BffFactory();
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", null, null)); factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "REG-abc", null, null));
factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", null, null)); factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", "REG-def", null, null));
var rows = await factory.CreateClient() var rows = await factory.CreateClient()
.GetFromJsonAsync<List<OpenbaarEntry>>("/openbaar/register?q=abc"); .GetFromJsonAsync<List<OpenbaarEntry>>("/openbaar/register?q=abc");

View File

@@ -6,8 +6,8 @@ public class OpenbaarProjectionTests
{ {
private static readonly ProjectionEntry[] Sample = private static readonly ProjectionEntry[] Sample =
[ [
new("abc-111", "INGEDIEND", "123456782", "Jan"), new("abc-111", "INGEDIEND", Reference: "REG-A", Bsn: "123456782", NaamPlaceholder: "Jan"),
new("def-222", "INGEDIEND", "987654321", "Piet"), new("def-222", "INGESCHREVEN", Reference: "REG-B", Bsn: "987654321", NaamPlaceholder: "Piet"),
]; ];
[Fact] [Fact]
@@ -18,31 +18,47 @@ public class OpenbaarProjectionTests
Assert.Equal(2, view.Count); Assert.Equal(2, view.Count);
Assert.Equal("abc-111", view[0].Id); Assert.Equal("abc-111", view[0].Id);
Assert.Equal("INGEDIEND", view[0].Status); Assert.Equal("INGEDIEND", view[0].Status);
// The public reference (zaak identificatie) is surfaced so it matches the submit confirmation (#78).
Assert.Equal("REG-A", view[0].Reference);
} }
[Fact] [Fact]
public void Public_view_exposes_only_id_and_status() public void Public_view_exposes_only_id_status_and_reference()
{ {
// OpenbaarEntry structurally carries only Id + Status — bsn/naam can never leak. // OpenbaarEntry structurally carries only public-safe fields — bsn/naam can never leak.
var props = typeof(OpenbaarEntry).GetProperties().Select(p => p.Name).ToArray(); var props = typeof(OpenbaarEntry).GetProperties().Select(p => p.Name).ToArray();
Assert.Equal(["Id", "Status"], props); Assert.Equal(["Id", "Status", "Reference"], props);
} }
[Theory] [Theory]
[InlineData("abc", 1)] [InlineData("abc", 1)] // by id
[InlineData("ABC", 1)] [InlineData("ABC", 1)] // by id, case-insensitive
[InlineData("2", 1)] [InlineData("def", 1)] // by id
[InlineData("zzz", 0)] [InlineData("zzz", 0)] // no match
public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected) public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected)
{ => Assert.Equal(expected, OpenbaarProjection.PublicView(Sample, q).Count);
var view = OpenbaarProjection.PublicView(Sample, q);
Assert.Equal(expected, view.Count); [Theory]
} [InlineData("REG-A", 1)] // by reference — the citizen's confirmation reference
[InlineData("reg-a", 1)] // by reference, case-insensitive
[InlineData("REG", 2)] // both share the prefix
public void Filters_by_reference_containing_the_query_case_insensitively(string q, int expected)
=> Assert.Equal(expected, OpenbaarProjection.PublicView(Sample, q).Count);
[Fact] [Fact]
public void Blank_query_is_treated_as_no_filter() public void Blank_query_is_treated_as_no_filter()
{ {
Assert.Equal(2, OpenbaarProjection.PublicView(Sample, " ").Count); Assert.Equal(2, OpenbaarProjection.PublicView(Sample, " ").Count);
} }
[Fact]
public void A_row_without_a_reference_never_matches_a_query()
{
// Older rows can have a null reference (the column is additive, #78/ADR-0012). A search must
// simply not match them — it must not throw and must not treat "no reference" as a match.
var entries = new[] { new ProjectionEntry("xyz-999", "INGEDIEND", Reference: null, Bsn: null, NaamPlaceholder: null) };
Assert.Empty(OpenbaarProjection.PublicView(entries, "REG"));
Assert.Equal("xyz-999", Assert.Single(OpenbaarProjection.PublicView(entries, "xyz")).Id);
}
} }

View File

@@ -67,7 +67,8 @@
"OpenbaarEntry": { "OpenbaarEntry": {
"required": [ "required": [
"id", "id",
"status" "status",
"reference"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@@ -76,6 +77,12 @@
}, },
"status": { "status": {
"type": "string" "type": "string"
},
"reference": {
"type": [
"null",
"string"
]
} }
} }
}, },

View File

@@ -28,7 +28,7 @@ public sealed class OpenZaakWorker(IRegistrationStore store, IAclClient acl)
if (registration.ZaakUrl is not null) if (registration.ZaakUrl is not null)
return registration.ZaakUrl; return registration.ZaakUrl;
var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, ct); var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, registration.Id.ToString(), ct);
registration.AttachZaak(zaakUrl); registration.AttachZaak(zaakUrl);
await store.SaveAsync(registration, ct); await store.SaveAsync(registration, ct);
return zaakUrl; return zaakUrl;

View File

@@ -24,7 +24,10 @@ public interface IWorkflowClient
/// </summary> /// </summary>
public interface IAclClient public interface IAclClient
{ {
Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default); /// <summary>Open a zaak for the registration. <paramref name="reference"/> is the registration's
/// own reference (its id); the ACL records it as the zaak's identificatie so the openbaar register
/// can show the same reference the zorgprofessional was given (S-09b follow-up, adr-proposal #78).</summary>
Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default);
/// <summary> /// <summary>
/// Record the approval on the given zaak. The ACL translates this to the ZGW concept — setting /// Record the approval on the given zaak. The ACL translates this to the ZGW concept — setting

View File

@@ -11,10 +11,10 @@ namespace Big.Infrastructure;
/// </summary> /// </summary>
public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient
{ {
public async Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default) public async Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default)
{ {
using var response = await http.PostAsJsonAsync( using var response = await http.PostAsJsonAsync(
new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn), ct); new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn, reference), ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var opened = await response.Content.ReadFromJsonAsync<OpenZaakResponse>(ct) var opened = await response.Content.ReadFromJsonAsync<OpenZaakResponse>(ct)
@@ -31,7 +31,9 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn); private sealed record OpenZaakRequest(
[property: JsonPropertyName("bsn")] string Bsn,
[property: JsonPropertyName("reference")] string Reference);
private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl); private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl);

View File

@@ -15,12 +15,13 @@ public class AclHttpClientTests
var client = Client(capture.Responds(HttpStatusCode.OK, var client = Client(capture.Responds(HttpStatusCode.OK,
"""{"zaakUrl":"http://openzaak/zaken/api/v1/zaken/abc"}""")); """{"zaakUrl":"http://openzaak/zaken/api/v1/zaken/abc"}"""));
var url = await client.OpenZaakAsync("123456782"); var url = await client.OpenZaakAsync("123456782", "reg-42");
Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", url.ToString()); Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", url.ToString());
Assert.Equal(HttpMethod.Post, capture.Seen!.Method); Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://acl/zaken", capture.Seen.RequestUri!.ToString()); Assert.Equal("http://acl/zaken", capture.Seen.RequestUri!.ToString());
Assert.Contains("\"bsn\":\"123456782\"", capture.Body); Assert.Contains("\"bsn\":\"123456782\"", capture.Body);
Assert.Contains("\"reference\":\"reg-42\"", capture.Body);
} }
[Fact] [Fact]
@@ -29,7 +30,7 @@ public class AclHttpClientTests
var capture = new RequestCapture(); var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.BadGateway)); var client = Client(capture.Responds(HttpStatusCode.BadGateway));
await Assert.ThrowsAsync<HttpRequestException>(() => client.OpenZaakAsync("123456782")); await Assert.ThrowsAsync<HttpRequestException>(() => client.OpenZaakAsync("123456782", "reg-1"));
} }
[Fact] [Fact]
@@ -38,7 +39,7 @@ public class AclHttpClientTests
var capture = new RequestCapture(); var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, "null")); var client = Client(capture.Responds(HttpStatusCode.OK, "null"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782")); var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782", "reg-1"));
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
} }

View File

@@ -50,15 +50,17 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
private readonly Uri _zaakUrl = zaakUrl ?? DefaultZaakUrl; private readonly Uri _zaakUrl = zaakUrl ?? DefaultZaakUrl;
public string? OpenedForBsn { get; private set; } public string? OpenedForBsn { get; private set; }
public string? OpenedWithReference { get; private set; }
public int CallCount { get; private set; } public int CallCount { get; private set; }
public Uri? ApprovedZaakUrl { get; private set; } public Uri? ApprovedZaakUrl { get; private set; }
public int ApproveCallCount { get; private set; } public int ApproveCallCount { get; private set; }
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default) public Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default)
{ {
CallCount++; CallCount++;
OpenedForBsn = bsn; OpenedForBsn = bsn;
OpenedWithReference = reference;
return Task.FromResult(_zaakUrl); return Task.FromResult(_zaakUrl);
} }

View File

@@ -25,6 +25,9 @@ public class OpenZaakWorkerTests
Assert.Equal(FakeAclClient.DefaultZaakUrl, zaakUrl); Assert.Equal(FakeAclClient.DefaultZaakUrl, zaakUrl);
Assert.Equal("123456782", acl.OpenedForBsn); Assert.Equal("123456782", acl.OpenedForBsn);
// The registration's own id is handed to the ACL as the zaak reference (identificatie),
// so the openbaar register can show the reference the zorgprofessional received (#78).
Assert.Equal(registration.Id.ToString(), acl.OpenedWithReference);
var saved = await store.GetAsync(registration.Id); var saved = await store.GetAsync(registration.Id);
Assert.Equal(FakeAclClient.DefaultZaakUrl, saved!.ZaakUrl); Assert.Equal(FakeAclClient.DefaultZaakUrl, saved!.ZaakUrl);
Assert.Equal(1, store.SaveCount); Assert.Equal(1, store.SaveCount);

View File

@@ -0,0 +1,30 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using EventSubscriber.Application;
namespace EventSubscriber.Api;
/// <summary>
/// HTTP client to the ACL service. The subscriber enriches the projection with the zaak's reference
/// (identificatie) by asking the ACL — the only code that may read ZGW (§8.1) — rather than reading
/// OpenZaak itself (adr-proposal #78).
/// </summary>
public sealed class AclHttpClient(HttpClient http) : IAclClient
{
public async Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
using var response = await http.PostAsJsonAsync(
new Uri(http.BaseAddress!, "zaken/reference"), new ReferenceRequest(zaakUrl.ToString()), ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<ReferenceResponse>(ct)
?? throw new InvalidOperationException("The ACL returned an empty reference response.");
return body.Reference;
}
private sealed record ReferenceRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
private sealed record ReferenceResponse([property: JsonPropertyName("reference")] string Reference);
}

View File

@@ -11,9 +11,15 @@ var connectionString = builder.Configuration.GetConnectionString("Projection")
// this value (ADR-0007), so the webhook enforces it. // this value (ADR-0007), so the webhook enforces it.
var webhookToken = builder.Configuration["EventSubscriber:Webhook:AuthToken"] var webhookToken = builder.Configuration["EventSubscriber:Webhook:AuthToken"]
?? throw new InvalidOperationException("Missing configuration 'EventSubscriber:Webhook:AuthToken'"); ?? throw new InvalidOperationException("Missing configuration 'EventSubscriber:Webhook:AuthToken'");
// The ACL is the only code that may read ZGW (§8.1); the subscriber enriches the projection with the
// zaak reference through it (adr-proposal #78).
var aclBaseUrl = builder.Configuration["Acl:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Acl:BaseUrl'");
builder.Services.AddProjectionReadModel(connectionString); builder.Services.AddProjectionReadModel(connectionString);
builder.Services.AddProjectionWriteSide(); builder.Services.AddProjectionWriteSide();
builder.Services.AddHttpClient<EventSubscriber.Application.IAclClient, EventSubscriber.Api.AclHttpClient>(
c => c.BaseAddress = new Uri(aclBaseUrl));
var app = builder.Build(); var app = builder.Build();

View File

@@ -24,10 +24,12 @@ public sealed record Notification(
public bool IsZaakStatusSet => public bool IsZaakStatusSet =>
Kanaal == "zaken" && Resource == "status" && Actie == "create"; Kanaal == "zaken" && Resource == "status" && Actie == "create";
/// <summary>The zaak UUID used as the projection key. For a status notification the resource URL is /// <summary>The zaak URL this notification concerns — <c>hoofdObject</c> (the zaak) for a status
/// the status, so the zaak comes from <c>hoofdObject</c> (which, for a zaak-create, equals the /// notification, else the resource URL (which, for a zaak-create, is the zaak).</summary>
/// resource URL) — see the NRC payload note.</summary> public Uri ZaakUrl => HoofdObject ?? ResourceUrl;
public string ZaakId => (HoofdObject ?? ResourceUrl).Segments[^1].Trim('/');
/// <summary>The zaak UUID used as the projection key — the trailing segment of <see cref="ZaakUrl"/>.</summary>
public string ZaakId => ZaakUrl.Segments[^1].Trim('/');
/// <summary> /// <summary>
/// A deterministic dedup key. Open Notificaties carries no notification id and may /// A deterministic dedup key. Open Notificaties carries no notification id and may

View File

@@ -5,17 +5,19 @@ namespace EventSubscriber.Application;
/// out-of-order deliveries (CLAUDE.md §8.6): the notification log dedups, and the projection /// out-of-order deliveries (CLAUDE.md §8.6): the notification log dedups, and the projection
/// upsert is idempotent on the zaak id. Rebuilds the projection by replaying the log. /// upsert is idempotent on the zaak id. Rebuilds the projection by replaying the log.
/// </summary> /// </summary>
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store) public sealed class NotificationProjector(INotificationLog log, IProjectionStore store, IAclClient acl)
{ {
/// <summary>Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a /// <summary>Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a
/// status being set (INGESCHREVEN); ignores everything else.</summary> /// status being set (INGESCHREVEN); ignores everything else. Enriches the row with the zaak's
/// reference via the ACL (§8.1) and records it so a rebuild needs no ZGW access (#78).</summary>
public async Task HandleAsync(Notification notification, CancellationToken ct = default) public async Task HandleAsync(Notification notification, CancellationToken ct = default)
{ {
if (!notification.IsZaakCreated && !notification.IsZaakStatusSet) if (!notification.IsZaakCreated && !notification.IsZaakStatusSet)
return; return;
var reference = await acl.GetZaakReferenceAsync(notification.ZaakUrl, ct);
var recorded = new RecordedNotification( var recorded = new RecordedNotification(
notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource); notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource, reference);
// Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped // Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped
// before it touches the projection, so the projection stays a faithful derived artefact. // before it touches the projection, so the projection stays a faithful derived artefact.
@@ -36,7 +38,8 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore
/// <summary>The projection row for an accepted notification: a status-set maps to INGESCHREVEN, /// <summary>The projection row for an accepted notification: a status-set maps to INGESCHREVEN,
/// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008).</summary> /// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008).</summary>
private static RegisterEntry ToEntry(RecordedNotification recorded) private static RegisterEntry ToEntry(RecordedNotification recorded)
=> new(recorded.ZaakId, recorded.Resource == "status" => new(
? RegistrationStatus.Ingeschreven recorded.ZaakId,
: RegistrationStatus.Ingediend); recorded.Resource == "status" ? RegistrationStatus.Ingeschreven : RegistrationStatus.Ingediend,
Reference: recorded.Reference);
} }

View File

@@ -20,9 +20,20 @@ public interface INotificationLog
} }
/// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its /// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its
/// projection row — including the ZGW <c>resource</c>, which distinguishes a zaak-create (INGEDIEND) /// projection row — the ZGW <c>resource</c> (zaak-create INGEDIEND vs status-set → INGESCHREVEN) and
/// from a status-set (INGESCHREVEN) so a rebuild reproduces the right status.</summary> /// the zaak <c>reference</c> (identificatie), so a rebuild reproduces the row without re-reading ZGW (#78).</summary>
public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource); public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource, string? Reference);
/// <summary>
/// Port to the Anti-Corruption Layer. The subscriber enriches the projection with the zaak's
/// public-safe reference (its identificatie) by asking the ACL — the only code that may read ZGW
/// (§8.1) — rather than reading OpenZaak itself (adr-proposal #78).
/// </summary>
public interface IAclClient
{
/// <summary>The zaak's reference (identificatie) for the read projection.</summary>
Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default);
}
/// <summary>The read projection store. Owned by the projection bounded context (ADR-0008); the /// <summary>The read projection store. Owned by the projection bounded context (ADR-0008); the
/// subscriber writes to it and the projection-api reads it.</summary> /// subscriber writes to it and the projection-api reads it.</summary>

View File

@@ -9,6 +9,7 @@ namespace EventSubscriber.Application;
public sealed record RegisterEntry( public sealed record RegisterEntry(
string Id, string Id,
string Status, string Status,
string? Reference = null,
string? Bsn = null, string? Bsn = null,
string? NaamPlaceholder = null); string? NaamPlaceholder = null);

View File

@@ -0,0 +1,86 @@
using System.Net;
using System.Text;
using EventSubscriber.Api;
namespace EventSubscriber.Tests;
/// <summary>
/// Unit tests for the subscriber's ACL client, which reads a zaak's reference (identificatie) through
/// the ACL — the only code allowed to talk to ZGW (§8.1, #78). Uses a scripted message handler so no
/// real ACL is required.
/// </summary>
public class AclHttpClientTests
{
private static AclHttpClient Client(StubHandler handler) =>
new(new HttpClient(handler) { BaseAddress = new Uri("http://acl/") });
[Fact]
public async Task Reads_a_zaak_reference_by_posting_the_zaak_url_and_returns_it()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, """{"reference":"REG-42"}"""));
var reference = await client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
Assert.Equal("REG-42", reference);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://acl/zaken/reference", capture.Seen.RequestUri!.ToString());
Assert.Contains("\"zaakUrl\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body);
}
[Fact]
public async Task Throws_when_the_acl_rejects_the_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
await Assert.ThrowsAsync<HttpRequestException>(
() => client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
}
[Fact]
public async Task Throws_when_the_acl_returns_an_empty_body()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, "null"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Rejects_a_null_zaak_url_without_sending_a_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, """{"reference":"REG-1"}"""));
await Assert.ThrowsAsync<ArgumentNullException>(() => client.GetZaakReferenceAsync(null!));
Assert.Null(capture.Seen);
}
}
/// <summary>A test double for <see cref="HttpMessageHandler"/> that records the last request and
/// returns a scripted response — mirrors the ACL/domain gateway tests.</summary>
internal sealed class StubHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> onSend) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> onSend(request);
}
/// <summary>Captures the request a client sent, including the (buffered) body.</summary>
internal sealed class RequestCapture
{
public HttpRequestMessage? Seen { get; private set; }
public string? Body { get; private set; }
public StubHandler Responds(HttpStatusCode status, string? json = null) => new(async req =>
{
Seen = req;
Body = req.Content is null ? null : await req.Content.ReadAsStringAsync();
var response = new HttpResponseMessage(status);
if (json is not null)
response.Content = new StringContent(json, Encoding.UTF8, "application/json");
return response;
});
}

View File

@@ -19,6 +19,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\EventSubscriber.Api\EventSubscriber.Api.csproj" />
<ProjectReference Include="..\EventSubscriber.Application\EventSubscriber.Application.csproj" /> <ProjectReference Include="..\EventSubscriber.Application\EventSubscriber.Application.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -5,6 +5,19 @@ namespace EventSubscriber.Tests;
/// <summary>In-memory stand-ins for the projection store and notification log, so the /// <summary>In-memory stand-ins for the projection store and notification log, so the
/// projector's behaviour is exercised without Postgres (hand-written stubs, the repo's /// projector's behaviour is exercised without Postgres (hand-written stubs, the repo's
/// convention — no mocking library).</summary> /// convention — no mocking library).</summary>
/// <summary>A fake ACL client that returns a fixed reference derived from the zaak, and records
/// how many times it was called (to prove a rebuild does not re-read via the ACL).</summary>
internal sealed class FakeAclClient : IAclClient
{
public int CallCount { get; private set; }
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
CallCount++;
return Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/'));
}
}
internal sealed class InMemoryNotificationLog : INotificationLog internal sealed class InMemoryNotificationLog : INotificationLog
{ {
private readonly Dictionary<string, RecordedNotification> _byKey = []; private readonly Dictionary<string, RecordedNotification> _byKey = [];

View File

@@ -12,8 +12,9 @@ public sealed class NotificationProjectorTests
private readonly InMemoryNotificationLog _log = new(); private readonly InMemoryNotificationLog _log = new();
private readonly InMemoryProjectionStore _store = new(); private readonly InMemoryProjectionStore _store = new();
private readonly FakeAclClient _acl = new();
private NotificationProjector Projector() => new(_log, _store); private NotificationProjector Projector() => new(_log, _store, _acl);
private static Notification ZaakCreated(string url = ZaakUrl) private static Notification ZaakCreated(string url = ZaakUrl)
=> new("zaken", "zaak", "create", new Uri(url)); => new("zaken", "zaak", "create", new Uri(url));
@@ -30,6 +31,23 @@ public sealed class NotificationProjectorTests
var entry = Assert.Single(await _store.AllAsync()); var entry = Assert.Single(await _store.AllAsync());
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id); Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
Assert.Equal(RegistrationStatus.Ingediend, entry.Status); Assert.Equal(RegistrationStatus.Ingediend, entry.Status);
// Enriched with the zaak's reference (identificatie), fetched via the ACL (#78).
Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference);
}
[Fact]
public async Task rebuild_reproduces_the_reference_without_re_reading_via_the_acl()
{
var projector = Projector();
await projector.HandleAsync(ZaakCreated());
var callsAfterProjection = _acl.CallCount;
await projector.RebuildAsync();
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference);
// Rebuild replays the log (which stored the reference) — no extra ACL calls (#78, ADR-0008).
Assert.Equal(callsAfterProjection, _acl.CallCount);
} }
[Fact] [Fact]

View File

@@ -16,6 +16,7 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
Actie = notification.Actie, Actie = notification.Actie,
ZaakId = notification.ZaakId, ZaakId = notification.ZaakId,
Resource = notification.Resource, Resource = notification.Resource,
Reference = notification.Reference,
ReceivedAt = DateTimeOffset.UtcNow, ReceivedAt = DateTimeOffset.UtcNow,
}); });
@@ -35,6 +36,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default) public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
=> await db.ProcessedNotifications => await db.ProcessedNotifications
.OrderBy(r => r.ReceivedAt) .OrderBy(r => r.ReceivedAt)
.Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource)) .Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource, r.Reference))
.ToListAsync(ct); .ToListAsync(ct);
} }

View File

@@ -15,6 +15,7 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
{ {
Id = entry.Id, Id = entry.Id,
Status = entry.Status, Status = entry.Status,
Reference = entry.Reference,
Bsn = entry.Bsn, Bsn = entry.Bsn,
NaamPlaceholder = entry.NaamPlaceholder, NaamPlaceholder = entry.NaamPlaceholder,
}); });
@@ -22,6 +23,7 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
else else
{ {
row.Status = entry.Status; row.Status = entry.Status;
row.Reference = entry.Reference;
row.Bsn = entry.Bsn; row.Bsn = entry.Bsn;
row.NaamPlaceholder = entry.NaamPlaceholder; row.NaamPlaceholder = entry.NaamPlaceholder;
} }
@@ -35,6 +37,6 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
public async Task<IReadOnlyList<RegisterEntry>> AllAsync(CancellationToken ct = default) public async Task<IReadOnlyList<RegisterEntry>> AllAsync(CancellationToken ct = default)
=> await db.RegisterEntries => await db.RegisterEntries
.OrderBy(r => r.Id) .OrderBy(r => r.Id)
.Select(r => new RegisterEntry(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) .Select(r => new RegisterEntry(r.Id, r.Status, Reference: r.Reference, Bsn: r.Bsn, NaamPlaceholder: r.NaamPlaceholder))
.ToListAsync(ct); .ToListAsync(ct);
} }

View File

@@ -0,0 +1,92 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Projection.ReadModel;
#nullable disable
namespace Projection.ReadModel.Migrations
{
[DbContext(typeof(ProjectionDbContext))]
[Migration("20260714101642_AddReferenceColumns")]
partial class AddReferenceColumns
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Projection.ReadModel.ProcessedNotificationRow", b =>
{
b.Property<string>("Key")
.HasColumnType("text")
.HasColumnName("key");
b.Property<string>("Actie")
.IsRequired()
.HasColumnType("text")
.HasColumnName("actie");
b.Property<DateTimeOffset>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.Property<string>("Reference")
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("text")
.HasColumnName("resource");
b.Property<string>("ZaakId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("zaak_id");
b.HasKey("Key");
b.ToTable("processed_notifications", (string)null);
});
modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b =>
{
b.Property<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<string>("Bsn")
.HasColumnType("text")
.HasColumnName("bsn");
b.Property<string>("NaamPlaceholder")
.HasColumnType("text")
.HasColumnName("naam_placeholder");
b.Property<string>("Reference")
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Id");
b.ToTable("register_projection", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Projection.ReadModel.Migrations
{
/// <inheritdoc />
public partial class AddReferenceColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "reference",
table: "register_projection",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "reference",
table: "processed_notifications",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "reference",
table: "register_projection");
migrationBuilder.DropColumn(
name: "reference",
table: "processed_notifications");
}
}
}

View File

@@ -37,6 +37,10 @@ namespace Projection.ReadModel.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("received_at"); .HasColumnName("received_at");
b.Property<string>("Reference")
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("Resource") b.Property<string>("Resource")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")
@@ -66,6 +70,10 @@ namespace Projection.ReadModel.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("naam_placeholder"); .HasColumnName("naam_placeholder");
b.Property<string>("Reference")
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("Status") b.Property<string>("Status")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")

View File

@@ -24,6 +24,7 @@ public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> op
e.HasKey(r => r.Id); e.HasKey(r => r.Id);
e.Property(r => r.Id).HasColumnName("id"); e.Property(r => r.Id).HasColumnName("id");
e.Property(r => r.Status).HasColumnName("status").IsRequired(); e.Property(r => r.Status).HasColumnName("status").IsRequired();
e.Property(r => r.Reference).HasColumnName("reference");
e.Property(r => r.Bsn).HasColumnName("bsn"); e.Property(r => r.Bsn).HasColumnName("bsn");
e.Property(r => r.NaamPlaceholder).HasColumnName("naam_placeholder"); e.Property(r => r.NaamPlaceholder).HasColumnName("naam_placeholder");
}); });
@@ -36,6 +37,7 @@ public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> op
e.Property(r => r.Actie).HasColumnName("actie").IsRequired(); e.Property(r => r.Actie).HasColumnName("actie").IsRequired();
e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired(); e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired();
e.Property(r => r.Resource).HasColumnName("resource").IsRequired(); e.Property(r => r.Resource).HasColumnName("resource").IsRequired();
e.Property(r => r.Reference).HasColumnName("reference");
e.Property(r => r.ReceivedAt).HasColumnName("received_at"); e.Property(r => r.ReceivedAt).HasColumnName("received_at");
}); });
} }
@@ -46,6 +48,10 @@ public sealed class RegisterEntryRow
{ {
public required string Id { get; set; } public required string Id { get; set; }
public required string Status { get; set; } public required string Status { get; set; }
/// <summary>The citizen-facing reference (the zaak's identificatie) — matches the submit confirmation (#78).</summary>
public string? Reference { get; set; }
public string? Bsn { get; set; } public string? Bsn { get; set; }
public string? NaamPlaceholder { get; set; } public string? NaamPlaceholder { get; set; }
} }
@@ -61,5 +67,8 @@ public sealed class ProcessedNotificationRow
/// the right status without reading OpenZaak (S-09b).</summary> /// the right status without reading OpenZaak (S-09b).</summary>
public required string Resource { get; set; } public required string Resource { get; set; }
/// <summary>The zaak reference (identificatie), retained so a rebuild reprojects it without the ACL (#78).</summary>
public string? Reference { get; set; }
public DateTimeOffset ReceivedAt { get; set; } public DateTimeOffset ReceivedAt { get; set; }
} }

View File

@@ -23,7 +23,7 @@ app.MapGet("/register", async (ProjectionDbContext db, CancellationToken ct) =>
{ {
var rows = await db.RegisterEntries var rows = await db.RegisterEntries
.OrderBy(r => r.Id) .OrderBy(r => r.Id)
.Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Reference, r.Bsn, r.NaamPlaceholder))
.ToListAsync(ct); .ToListAsync(ct);
return Results.Ok(rows); return Results.Ok(rows);
}); });
@@ -32,13 +32,13 @@ app.MapGet("/register/{id}", async (string id, ProjectionDbContext db, Cancellat
{ {
var row = await db.RegisterEntries var row = await db.RegisterEntries
.Where(r => r.Id == id) .Where(r => r.Id == id)
.Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Reference, r.Bsn, r.NaamPlaceholder))
.SingleOrDefaultAsync(ct); .SingleOrDefaultAsync(ct);
return row is null ? Results.NotFound() : Results.Ok(row); return row is null ? Results.NotFound() : Results.Ok(row);
}); });
await app.RunAsync(); await app.RunAsync();
public sealed record RegisterEntryDto(string Id, string Status, string? Bsn, string? NaamPlaceholder); public sealed record RegisterEntryDto(string Id, string Status, string? Reference, string? Bsn, string? NaamPlaceholder);
public partial class Program; public partial class Program;

View File

@@ -31,7 +31,7 @@ public sealed class BffToegangSteps : IDisposable
[Given("the projection contains a zaak \"(.*)\" for BSN \"(.*)\"")] [Given("the projection contains a zaak \"(.*)\" for BSN \"(.*)\"")]
public void GivenTheProjectionContains(string zaakId, string bsn) public void GivenTheProjectionContains(string zaakId, string bsn)
{ {
_host.Projection.Entries.Add(new ProjectionEntry(zaakId, "INGEDIEND", bsn, null)); _host.Projection.Entries.Add(new ProjectionEntry(zaakId, "INGEDIEND", "REG-" + zaakId, bsn, null));
_client = _host.CreateClient(); _client = _host.CreateClient();
} }

View File

@@ -18,7 +18,7 @@ public sealed class EenZaakOpenenSteps
[Given("a domain registration for BSN \"(.*)\"")] [Given("a domain registration for BSN \"(.*)\"")]
public void GivenADomainRegistrationForBsn(string bsn) public void GivenADomainRegistrationForBsn(string bsn)
=> _registration = new DomainRegistration(bsn); => _registration = new DomainRegistration(bsn, "ACC-REF-1");
[Given("the ACL is configured with these defaults:")] [Given("the ACL is configured with these defaults:")]
public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults) public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults)

View File

@@ -17,7 +17,8 @@ public sealed class RegisterProjectieBijwerkenSteps
private readonly NotificationProjector _projector; private readonly NotificationProjector _projector;
private Notification? _notification; private Notification? _notification;
public RegisterProjectieBijwerkenSteps() => _projector = new NotificationProjector(_log, _store); public RegisterProjectieBijwerkenSteps()
=> _projector = new NotificationProjector(_log, _store, new InMemoryAclReferenceClient());
[Given("a zaak is created in OpenZaak with id \"(.*)\"")] [Given("a zaak is created in OpenZaak with id \"(.*)\"")]
public void GivenAZaakIsCreatedInOpenZaakWithId(string id) public void GivenAZaakIsCreatedInOpenZaakWithId(string id)

View File

@@ -26,11 +26,13 @@ public sealed class InMemoryAclClient : IAclClient
public static readonly Uri OpenedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-zaak"); public static readonly Uri OpenedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-zaak");
public string? OpenedForBsn { get; private set; } public string? OpenedForBsn { get; private set; }
public string? OpenedWithReference { get; private set; }
public Uri? ApprovedZaakUrl { get; private set; } public Uri? ApprovedZaakUrl { get; private set; }
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default) public Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default)
{ {
OpenedForBsn = bsn; OpenedForBsn = bsn;
OpenedWithReference = reference;
return Task.FromResult(OpenedZaakUrl); return Task.FromResult(OpenedZaakUrl);
} }

View File

@@ -38,3 +38,11 @@ public sealed class InMemoryProjectionStore : IProjectionStore
public IReadOnlyList<RegisterEntry> RowsFor(string id) public IReadOnlyList<RegisterEntry> RowsFor(string id)
=> [.. _byId.Values.Where(e => e.Id == id)]; => [.. _byId.Values.Where(e => e.Id == id)];
} }
/// <summary>A fake ACL client for the projection acceptance scenario: returns a reference derived
/// from the zaak, so the projector can enrich rows without a running ACL (#78).</summary>
public sealed class InMemoryAclReferenceClient : IAclClient
{
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
=> Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/'));
}

View File

@@ -24,4 +24,7 @@ public sealed class InMemoryZaakGateway : IZaakGateway
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet); Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
=> Task.FromResult("ACC-REF-1");
} }

View File

@@ -26,15 +26,22 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection // The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
// catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads // catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads
// on open, so reload until a submitted (INGEDIEND) row appears. // on open, so reload until *this* submission's row appears. We poll on the reference cell (not a
// generic INGEDIEND cell): the shared verify stack already holds INGEDIEND rows from earlier checks,
// so a status-only poll would short-circuit on a stale row before our row is projected.
await page.goto('http://openbaar/'); await page.goto('http://openbaar/');
await expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
// #78: the reference shown in the public register must be the exact one the citizen saw on the
// submit confirmation — no mismatch between the two portals.
await expect await expect
.poll(async () => { .poll(async () => {
await page.reload(); await page.reload();
return page.getByRole('cell', { name: 'INGEDIEND' }).count(); return page.getByRole('cell', { name: reference }).count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0); .toBeGreaterThan(0);
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
.toBeVisible();
// Approve via the temporary admin endpoint (reached directly on the compose network, as a // Approve via the temporary admin endpoint (reached directly on the compose network, as a
// behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request // behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request
@@ -49,11 +56,12 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH
const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`); const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
expect(approve.status()).toBe(204); expect(approve.status()).toBe(204);
// The approval flows back to the projection; the openbaar register now shows it as INGESCHREVEN. // The approval flows back to the projection; the openbaar register now shows *our* row (matched by
// its reference) as INGESCHREVEN.
await expect await expect
.poll(async () => { .poll(async () => {
await page.reload(); await page.reload();
return page.getByRole('cell', { name: 'INGESCHREVEN' }).count(); return page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0); .toBeGreaterThan(0);
}); });