diff --git a/apps/openbaar/src/app/register/register-page.html b/apps/openbaar/src/app/register/register-page.html index 2051621..4f76ffb 100644 --- a/apps/openbaar/src/app/register/register-page.html +++ b/apps/openbaar/src/app/register/register-page.html @@ -43,7 +43,7 @@ @for (entry of entries(); track entry.id) { - {{ entry.id }} + {{ entry.reference }} {{ entry.status }} } diff --git a/apps/openbaar/src/app/register/register-page.spec.ts b/apps/openbaar/src/app/register/register-page.spec.ts index d9dd6c9..b333cc9 100644 --- a/apps/openbaar/src/app/register/register-page.spec.ts +++ b/apps/openbaar/src/app/register/register-page.spec.ts @@ -5,8 +5,8 @@ import { axe } from 'vitest-axe'; import { RegisterPage } from './register-page'; const sample: OpenbaarEntry[] = [ - { id: 'zaak-abc', status: 'INGEDIEND' }, - { id: 'zaak-def', status: 'INGESCHREVEN' }, + { id: 'zaak-abc', status: 'INGEDIEND', reference: 'REG-abc' }, + { id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' }, ]; function providers(get = vi.fn().mockReturnValue(of(sample))) { @@ -22,9 +22,11 @@ describe('RegisterPage', () => { await render(RegisterPage, { providers: providers(get).providers }); 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(/zaak-def/)).toBeTruthy(); + expect(screen.getByText(/REG-def/)).toBeTruthy(); }); it('searches by the entered term', async () => { diff --git a/libs/api-client/src/lib/generated/bff-api.ts b/libs/api-client/src/lib/generated/bff-api.ts index 40a825d..5f76b6b 100644 --- a/libs/api-client/src/lib/generated/bff-api.ts +++ b/libs/api-client/src/lib/generated/bff-api.ts @@ -27,6 +27,8 @@ import { export interface OpenbaarEntry { id: string; status: string; + /** @nullable */ + reference: string | null; } export interface SubmitAccepted { diff --git a/services/bff/Bff.Api/DownstreamClients.cs b/services/bff/Bff.Api/DownstreamClients.cs index 2b66096..c03f166 100644 --- a/services/bff/Bff.Api/DownstreamClients.cs +++ b/services/bff/Bff.Api/DownstreamClients.cs @@ -6,11 +6,12 @@ namespace Bff.Api; public sealed record SubmitAccepted(string RegistrationId, string Status); /// A projection row as the projection-api serves it. Bsn/NaamPlaceholder are -/// 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); +/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09). +/// Reference is the public-safe citizen reference (the zaak identificatie, #78). +public sealed record ProjectionEntry(string Id, string Status, string? Reference, string? Bsn, string? NaamPlaceholder); /// A public-safe openbaar register row — only non-sensitive fields leave the BFF. -public sealed record OpenbaarEntry(string Id, string Status); +public sealed record OpenbaarEntry(string Id, string Status, string? Reference); /// Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out). public interface IDomainClient diff --git a/services/bff/Bff.Api/OpenbaarProjection.cs b/services/bff/Bff.Api/OpenbaarProjection.cs index 7f1ae33..050c327 100644 --- a/services/bff/Bff.Api/OpenbaarProjection.cs +++ b/services/bff/Bff.Api/OpenbaarProjection.cs @@ -11,8 +11,10 @@ public static class OpenbaarProjection { var filtered = string.IsNullOrWhiteSpace(q) ? 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))]; } } diff --git a/services/bff/Bff.Tests/OpenbaarEndpointTests.cs b/services/bff/Bff.Tests/OpenbaarEndpointTests.cs index 0fcb165..ee915b5 100644 --- a/services/bff/Bff.Tests/OpenbaarEndpointTests.cs +++ b/services/bff/Bff.Tests/OpenbaarEndpointTests.cs @@ -10,7 +10,7 @@ public class OpenbaarEndpointTests public async Task Serves_public_safe_rows_anonymously() { 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). var response = await factory.CreateClient().GetAsync("/openbaar/register"); @@ -19,7 +19,9 @@ public class OpenbaarEndpointTests var body = await response.Content.ReadAsStringAsync(); Assert.Contains("abc-111", 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); } @@ -27,8 +29,8 @@ public class OpenbaarEndpointTests public async Task Filters_by_the_query_parameter() { using var factory = new BffFactory(); - factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", null, null)); - factory.Projection.Entries.Add(new ProjectionEntry("def-222", "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", "REG-def", null, null)); var rows = await factory.CreateClient() .GetFromJsonAsync>("/openbaar/register?q=abc"); diff --git a/services/bff/Bff.Tests/OpenbaarProjectionTests.cs b/services/bff/Bff.Tests/OpenbaarProjectionTests.cs index a385808..666b94d 100644 --- a/services/bff/Bff.Tests/OpenbaarProjectionTests.cs +++ b/services/bff/Bff.Tests/OpenbaarProjectionTests.cs @@ -6,8 +6,8 @@ public class OpenbaarProjectionTests { private static readonly ProjectionEntry[] Sample = [ - new("abc-111", "INGEDIEND", "123456782", "Jan"), - new("def-222", "INGEDIEND", "987654321", "Piet"), + new("abc-111", "INGEDIEND", Reference: "REG-A", Bsn: "123456782", NaamPlaceholder: "Jan"), + new("def-222", "INGESCHREVEN", Reference: "REG-B", Bsn: "987654321", NaamPlaceholder: "Piet"), ]; [Fact] @@ -18,31 +18,47 @@ public class OpenbaarProjectionTests Assert.Equal(2, view.Count); Assert.Equal("abc-111", view[0].Id); 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] - 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(); - Assert.Equal(["Id", "Status"], props); + Assert.Equal(["Id", "Status", "Reference"], props); } [Theory] - [InlineData("abc", 1)] - [InlineData("ABC", 1)] - [InlineData("2", 1)] - [InlineData("zzz", 0)] + [InlineData("abc", 1)] // by id + [InlineData("ABC", 1)] // by id, case-insensitive + [InlineData("def", 1)] // by id + [InlineData("zzz", 0)] // no match public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected) - { - var view = OpenbaarProjection.PublicView(Sample, q); + => Assert.Equal(expected, OpenbaarProjection.PublicView(Sample, q).Count); - 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] public void Blank_query_is_treated_as_no_filter() { 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); + } } diff --git a/services/bff/openapi.json b/services/bff/openapi.json index f1305b2..92a9120 100644 --- a/services/bff/openapi.json +++ b/services/bff/openapi.json @@ -67,7 +67,8 @@ "OpenbaarEntry": { "required": [ "id", - "status" + "status", + "reference" ], "type": "object", "properties": { @@ -76,6 +77,12 @@ }, "status": { "type": "string" + }, + "reference": { + "type": [ + "null", + "string" + ] } } }, @@ -101,4 +108,4 @@ "name": "Bff.Api" } ] -} \ No newline at end of file +} diff --git a/tests/acceptance/Steps/BffToegangSteps.cs b/tests/acceptance/Steps/BffToegangSteps.cs index 7e19e04..165347b 100644 --- a/tests/acceptance/Steps/BffToegangSteps.cs +++ b/tests/acceptance/Steps/BffToegangSteps.cs @@ -31,7 +31,7 @@ public sealed class BffToegangSteps : IDisposable [Given("the projection contains a zaak \"(.*)\" for 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(); }