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:
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user