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>
This commit is contained in:
2026-07-14 14:46:55 +02:00
parent 566fc9bcea
commit 44e6f81f81
9 changed files with 62 additions and 30 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

@@ -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

@@ -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"
]
} }
} }
}, },
@@ -101,4 +108,4 @@
"name": "Bff.Api" "name": "Bff.Api"
} }
] ]
} }

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();
} }