From 446ea9474baf7b3a29d136d314a25e373772a530 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 23 Jul 2026 12:23:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(registratie):=20WP-36=20=E2=80=94=20admin?= =?UTF-8?q?=20cases=20page=20+=20admin=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-only overview of all cases across owners + an admin delete, gated by a new `cases:manage` capability (Authz role→cap + CanManageCases + CasesAdmin gate; FE capability + guard + nav + role.interceptor prefix — the org-template/stamdata recipe). Backend adds ApplicationStore.ListAll()/DeleteAny() and GET /admin/cases + DELETE /admin/cases/{id}; admin delete removes ANY case incl. submitted. Page lives in registratie/ui (owns the Aanvraag aggregate; reuses aanvraag-view + parse), routed /beheer/zaken; delete guarded by a native confirm, optimistic with rollback. Typed client regenerated (documents the new endpoints + owner field). Co-Authored-By: Claude Opus 4.8 --- backend/src/BigRegister.Api/Contracts/Dtos.cs | 3 +- .../src/BigRegister.Api/Contracts/Mappers.cs | 4 + .../BigRegister.Api/Data/ApplicationStore.cs | 35 +++++ .../Domain/Authorization/Authz.cs | 7 +- backend/src/BigRegister.Api/Program.cs | 32 +++++ backend/swagger.json | 71 ++++++++++ .../BigRegister.Tests/AdminCasesTests.cs | 71 ++++++++++ .../OrgTemplateEndpointTests.cs | 2 +- docs/project/backlog/README.md | 2 +- docs/project/backlog/WP-36-admin-cases.md | 49 +++++++ src/app/app.routes.ts | 10 ++ .../application/admin-cases.store.spec.ts | 55 ++++++++ .../application/admin-cases.store.ts | 57 ++++++++ src/app/registratie/domain/aanvraag.ts | 3 + .../infrastructure/applications.adapter.ts | 11 ++ src/app/registratie/ui/admin-cases.page.ts | 129 ++++++++++++++++++ src/app/shared/domain/capability.ts | 7 +- src/app/shared/infrastructure/api-client.ts | 89 ++++++++++++ src/app/shared/infrastructure/me.adapter.ts | 1 + .../shared/infrastructure/role.interceptor.ts | 1 + .../site-header/site-header.component.ts | 5 + src/locale/messages.en.xlf | 52 +++++++ src/locale/messages.xlf | 99 +++++++++++++- 23 files changed, 786 insertions(+), 9 deletions(-) create mode 100644 backend/tests/BigRegister.Tests/AdminCasesTests.cs create mode 100644 docs/project/backlog/WP-36-admin-cases.md create mode 100644 src/app/registratie/application/admin-cases.store.spec.ts create mode 100644 src/app/registratie/application/admin-cases.store.ts create mode 100644 src/app/registratie/ui/admin-cases.page.ts diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs index 8504d47..2771cba 100644 --- a/backend/src/BigRegister.Api/Contracts/Dtos.cs +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -96,7 +96,8 @@ public sealed record AanvraagStatusDto( public sealed record ApplicationSummaryDto( string Id, string Type, AanvraagStatusDto Status, IReadOnlyList DocumentIds, - string CreatedAt, string UpdatedAt, string? SubmittedAt); + string CreatedAt, string UpdatedAt, string? SubmittedAt, + string? Owner = null); // populated for the admin cross-owner list (WP-36); the user's own list ignores it public sealed record ApplicationDetailDto( string Id, string Type, AanvraagStatusDto Status, diff --git a/backend/src/BigRegister.Api/Contracts/Mappers.cs b/backend/src/BigRegister.Api/Contracts/Mappers.cs index bae2f46..17ea939 100644 --- a/backend/src/BigRegister.Api/Contracts/Mappers.cs +++ b/backend/src/BigRegister.Api/Contracts/Mappers.cs @@ -55,6 +55,10 @@ public static class Mappers a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds, a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o")); + /// Admin summary — same shape plus the owner (WP-36; the user-facing list leaves Owner null). + public static ApplicationSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) => + a.ToSummaryDto(now) with { Owner = a.Owner }; + public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new( a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds, a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o")); diff --git a/backend/src/BigRegister.Api/Data/ApplicationStore.cs b/backend/src/BigRegister.Api/Data/ApplicationStore.cs index fe2e595..9b84824 100644 --- a/backend/src/BigRegister.Api/Data/ApplicationStore.cs +++ b/backend/src/BigRegister.Api/Data/ApplicationStore.cs @@ -80,6 +80,19 @@ public static class ApplicationStore } } + /// Admin: every case across all owners (WP-36). The per-owner List is the norm; this + /// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint. + public static IReadOnlyList ListAll() + { + lock (_gate) + { + using var db = Db.Create(); + // Order client-side: SQLite can't ORDER BY a DateTimeOffset (same constraint the + // rest of the store sidesteps by never sorting in the query). + return db.Applications.ToList().OrderByDescending(a => a.UpdatedAt).ToList(); + } + } + /// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable. public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList? documentIds) { @@ -116,6 +129,28 @@ public static class ApplicationStore return true; } + /// Admin: delete ANY case regardless of owner or submitted state (WP-36). The + /// user-facing Delete refuses a submitted aanvraag and is owner-scoped; an admin + /// managing the register may remove any case. Cascades to the case's documents + /// using its own owner. Returns false only when the id doesn't exist. + public static bool DeleteAny(string id) + { + string owner; + List docs; + lock (_gate) + { + using var db = Db.Create(); + var a = db.Applications.Find(id); + if (a is null) return false; + owner = a.Owner; + docs = a.DocumentIds.ToList(); + db.Applications.Remove(a); + db.SaveChanges(); + } + foreach (var d in docs) DocumentStore.DeleteOwned(d, owner); + return true; + } + /// Submit transition. reject != null → Afgewezen; else accepted (In behandeling, /// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null /// if the aanvraag is gone or already submitted (idempotency guard). diff --git a/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs b/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs index af8e0ce..ed5f77f 100644 --- a/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs +++ b/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs @@ -43,7 +43,7 @@ public static class Authz public static IReadOnlyList RoleCapabilities(Principal principal) => principal.Role switch { PrincipalRole.Approver => new[] { "brief:approve", "brief:reject", "brief:send" }, - PrincipalRole.Admin => new[] { "orgtemplate:edit", "stamdata:edit" }, + PrincipalRole.Admin => new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage" }, _ => Array.Empty(), }; @@ -69,6 +69,11 @@ public static class Authz /// the maintenance editor consumes; the actual edit lands as a reviewed PR, not a write here. public static bool CanEditStamdata(Principal principal) => principal.Role == PrincipalRole.Admin; + /// Case management (WP-36): admin-only, resource-independent — same shape as + /// org-template / stamdata (role IS the decision). Gates the cross-owner /admin/cases + /// list + admin delete. + public static bool CanManageCases(Principal principal) => principal.Role == PrincipalRole.Admin; + /// Field-level PII (PRD-0002 §5c, phase P2): the case screen's BIG-nummer ships /// masked by default; only the behandelaar (Drafter) composing the case — the actor /// whose behandel-scherm shows the field — may reveal it. Role-based in the POC; a diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 213c4fa..4f1b554 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -309,6 +309,27 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re .ProducesProblem(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status404NotFound); +// --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. --- +api.MapGet("/admin/cases", (HttpContext ctx) => CasesAdmin(ctx, () => +{ + var now = DateTimeOffset.UtcNow; + return Results.Ok(ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList()); +})) +.Produces>() +.ProducesProblem(StatusCodes.Status403Forbidden); + +// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing +// DELETE /applications/{id}. A missing id is a 404. +api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () => +{ + if (!ApplicationStore.DeleteAny(id)) return Results.NotFound(); + app.Logger.LogInformation("admin case delete id={Id}", id); + return Results.NoContent(); +})) +.Produces(StatusCodes.Status204NoContent) +.Produces(StatusCodes.Status404NotFound) +.ProducesProblem(StatusCodes.Status403Forbidden); + // PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT // tied to a specific brief's live status — see BriefDecisionsDto for that). api.MapGet("/me", (HttpContext ctx) => new MeDto(Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx)))) @@ -515,6 +536,17 @@ IResult StamdataAdmin(HttpContext ctx, Func action) statusCode: StatusCodes.Status403Forbidden); } +// One gate for every admin-cases endpoint — the enforce twin of the `cases:manage` +// capability RoleCapabilities emits (single Authz source, WP-36). A denial is audited. +IResult CasesAdmin(HttpContext ctx, Func action) +{ + var principal = Authz.ResolvePrincipal(ctx); + if (Authz.CanManageCases(principal)) return action(); + AuditAuthz(ctx, "cases:manage", "cases", false, principal); + return Results.Problem(detail: "Alleen een beheerder mag aanvragen beheren.", + statusCode: StatusCodes.Status403Forbidden); +} + static StamdataColumnDto ToColumnDto(StamdataColumn c) => new(c.Name, c.Type, c.IsKey, c.Options); // Authorization audit (PRD-0002 §8): access-relevant decisions recorded with NO PII — diff --git a/backend/swagger.json b/backend/swagger.json index 5047798..cf4ceef 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -734,6 +734,73 @@ } } }, + "/api/v1/admin/cases": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplicationSummaryDto" + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/admin/cases/{id}": { + "delete": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/v1/me": { "get": { "tags": [ @@ -1370,6 +1437,10 @@ "submittedAt": { "type": "string", "nullable": true + }, + "owner": { + "type": "string", + "nullable": true } }, "additionalProperties": false diff --git a/backend/tests/BigRegister.Tests/AdminCasesTests.cs b/backend/tests/BigRegister.Tests/AdminCasesTests.cs new file mode 100644 index 0000000..9bfc9dc --- /dev/null +++ b/backend/tests/BigRegister.Tests/AdminCasesTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Net.Http.Json; +using BigRegister.Api.Contracts; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace BigRegister.Tests; + +/// WP-36: admin cross-owner case list + admin delete, gated by `cases:manage`. +public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture +{ + private readonly HttpClient _client = factory.CreateClient(); + + private HttpRequestMessage Admin(HttpMethod method, string path) + { + var req = new HttpRequestMessage(method, path); + req.Headers.Add("X-Role", "admin"); + return req; + } + + private async Task Create(string type) + { + var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type }); + Assert.Equal(HttpStatusCode.Created, res.StatusCode); + return (await res.Content.ReadFromJsonAsync())!; + } + + [Fact] + public async Task Admin_lists_every_case_with_its_owner() + { + var a = await Create("herregistratie"); + try + { + var list = await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/cases")); + list.EnsureSuccessStatusCode(); + var cases = (await list.Content.ReadFromJsonAsync>())!; + var mine = cases.Single(x => x.Id == a.Id); + Assert.False(string.IsNullOrEmpty(mine.Owner)); // admin list carries the owner + } + finally + { + await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/cases/{a.Id}")); + } + } + + [Fact] + public async Task Non_admin_is_forbidden() + { + Assert.Equal(HttpStatusCode.Forbidden, (await _client.GetAsync("/api/v1/admin/cases")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync("/api/v1/admin/cases/anything")).StatusCode); + } + + [Fact] + public async Task Admin_can_delete_a_submitted_case() + { + var a = await Create("registratie"); + (await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })) + .EnsureSuccessStatusCode(); + + // The user-facing DELETE refuses a submitted case (409); admin delete removes it. + var del = await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/cases/{a.Id}")); + Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode); + } + + [Fact] + public async Task Deleting_a_missing_case_is_not_found() + { + var del = await _client.SendAsync(Admin(HttpMethod.Delete, "/api/v1/admin/cases/does-not-exist")); + Assert.Equal(HttpStatusCode.NotFound, del.StatusCode); + } +} diff --git a/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs b/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs index 043fcfb..fdb6a79 100644 --- a/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs @@ -201,7 +201,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas { var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/me", role: "admin")); var me = await res.Content.ReadFromJsonAsync(); - Assert.Equal(new[] { "orgtemplate:edit", "stamdata:edit" }, me!.Capabilities); + Assert.Equal(new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage" }, me!.Capabilities); } [Fact] diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index a43ac72..d6c0750 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -80,7 +80,7 @@ for its existing violations, so every WP ends green. | [WP-33](WP-33-dev-switchers.md) | In-app dev switchers (scenario + role) | 7 · refinements | done | | [WP-34](WP-34-adres-phone-brp-readonly.md) | Adres: phone field + BRP address read-only | 7 · refinements | done | | [WP-35](WP-35-one-concept-per-type.md) | One Concept per case type (server-enforced) | 7 · refinements | done | -| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | todo | +| [WP-36](WP-36-admin-cases.md) | Admin cases page + admin delete | 7 · refinements | done | Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn); 03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed diff --git a/docs/project/backlog/WP-36-admin-cases.md b/docs/project/backlog/WP-36-admin-cases.md new file mode 100644 index 0000000..9591bbe --- /dev/null +++ b/docs/project/backlog/WP-36-admin-cases.md @@ -0,0 +1,49 @@ +# WP-36 — Admin cases page + admin delete + +Status: done +Phase: 7 — refinements + +## Why + +Admins can maintain stamdata and org-templates but have no view of the cases (aanvragen) in the +register, and no way to remove an erroneous one. This WP adds an admin-only overview of **all** +cases across owners and an admin **delete** that can remove any case — the back-office counterpart +of the user's own dashboard. + +## Decisions (made while building — no spec existed; flagged for review) + +- **Single capability `cases:manage`** covers both the list and the delete (one back-office + concern), following the `orgtemplate:edit` / `stamdata:edit` precedent exactly (Authz role→cap + + a `CanManageCases` gate + a `CasesAdmin(ctx,…)` helper; FE `Capability` union + `me.adapter` + `KNOWN` + `capabilityGuard` + nav item + `role.interceptor` prefix). +- **Page lives in `registratie` (not `beheer`).** `registratie` owns the `Aanvraag` aggregate, so + the admin view reuses its `aanvraag-view` labels + `parseApplications` trust boundary instead of + duplicating them — and it respects the layer boundary (`beheer` may not import `registratie`). + This matches the existing pattern (stamdata-admin lives in `beheer` because `beheer` owns + stamdata; org-template-admin in `brief`). Routed at `/beheer/zaken` for a legible admin URL. +- **Admin delete removes ANY case** — any owner, submitted or not — unlike the user-facing + `DELETE /applications/{id}` (owner-scoped, 409 on a submitted case). That is the admin power. +- **Native `confirm()` guards the delete.** No confirm-dialog component exists (the only precedent + is a native `confirm()` in behandel-scherm); the delete is irreversible, so it gets a prompt + rather than the dashboard's no-confirm optimistic cancel. +- **Single owner in practice.** Only `DemoOwner` exists, so the list shows that owner's cases with + an Owner column; no fake multi-user seed was added (the endpoint is cross-owner-capable — + `ListAll()` — so real multi-owner data would just appear). + +## Files + +- Backend: `ApplicationStore.ListAll()` + `DeleteAny(id)`; `ApplicationSummaryDto.Owner` + + `ToAdminSummaryDto`; `Authz` cap + `CanManageCases`; `Program.cs` `CasesAdmin` gate + `GET +/admin/cases` + `DELETE /admin/cases/{id}`; `AdminCasesTests` (+ update the org-template `/me` + cap-list assertion). SQLite can't `ORDER BY DateTimeOffset` → `ListAll` sorts client-side. +- FE: `capability.ts` + `me.adapter` `KNOWN` + `role.interceptor` (`/api/v1/admin/cases`); + `aanvraag.ts` `owner?`; `applications.adapter` `listAll`/`deleteAny` + parse owner; + `registratie/application/admin-cases.store.ts` (+spec); `registratie/ui/admin-cases.page.ts`; + route in `app.routes.ts`; nav item in `site-header`; new `$localize` ids + English targets. + +## Acceptance criteria + +- [x] Admin-only page at `/beheer/zaken` lists all cases (owner + type + status), gated by + `cases:manage` (denial alert for non-admins; server re-enforces via `CasesAdmin`). +- [x] Admin delete removes any case (incl. submitted); confirmed, optimistic with rollback. +- [x] `npm run ci` green (336 FE tests, backend 129, localized build, drift clean after commit). diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 0d55190..10818ff 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -74,6 +74,16 @@ export const routes: Routes = [ canActivate: [capabilityGuard('stamdata:edit')], loadComponent: () => import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage), }, + { + path: 'beheer/zaken', + // Admin-only cases overview + delete (WP-36): capabilityGuard denies-by-default + // unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the + // CasesAdmin gate — the guard just avoids loading a page that would 403. The page + // lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer. + canActivate: [capabilityGuard('cases:manage')], + loadComponent: () => + import('@registratie/ui/admin-cases.page').then((m) => m.AdminCasesPage), + }, { path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage), diff --git a/src/app/registratie/application/admin-cases.store.spec.ts b/src/app/registratie/application/admin-cases.store.spec.ts new file mode 100644 index 0000000..4b757dd --- /dev/null +++ b/src/app/registratie/application/admin-cases.store.spec.ts @@ -0,0 +1,55 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect, vi } from 'vitest'; +import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { AdminCasesStore } from './admin-cases.store'; + +const summary = (id: string) => ({ + id, + type: 'registratie', + status: { tag: 'Concept', stepIndex: 0, stepCount: 3 }, + documentIds: [], + createdAt: '2026-07-23T10:00:00Z', + updatedAt: '2026-07-23T10:00:00Z', + owner: '19012345601', +}); + +function setup(adapter: Partial): AdminCasesStore { + TestBed.configureTestingModule({ + providers: [{ provide: ApplicationsAdapter, useValue: adapter }], + }); + return TestBed.inject(AdminCasesStore); +} + +describe('AdminCasesStore', () => { + it('loads and parses the cross-owner list', async () => { + const store = setup({ listAll: () => Promise.resolve([summary('a'), summary('b')]) }); + await store.load(); + const s = store.cases(); + expect(s.tag).toBe('Success'); + expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a', 'b']); + }); + + it('deletes optimistically and confirms via the admin endpoint', async () => { + const deleteAny = vi.fn().mockResolvedValue(undefined); + const store = setup({ + listAll: () => Promise.resolve([summary('a'), summary('b')]), + deleteAny, + }); + await store.load(); + + await store.delete('a'); + expect(deleteAny).toHaveBeenCalledWith('a'); + const s = store.cases(); + expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']); + }); + + it('rolls back the removal when the delete fails', async () => { + const deleteAny = vi.fn().mockRejectedValue(new Error('boom')); + const store = setup({ listAll: () => Promise.resolve([summary('a')]), deleteAny }); + await store.load(); + + await store.delete('a'); + const s = store.cases(); + expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a']); // reappears + }); +}); diff --git a/src/app/registratie/application/admin-cases.store.ts b/src/app/registratie/application/admin-cases.store.ts new file mode 100644 index 0000000..943dcb4 --- /dev/null +++ b/src/app/registratie/application/admin-cases.store.ts @@ -0,0 +1,57 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { RemoteData } from '@shared/application/remote-data'; +import { Aanvraag } from '@registratie/domain/aanvraag'; +import { + ApplicationsAdapter, + parseApplications, +} from '@registratie/infrastructure/applications.adapter'; + +type Err = Error | undefined; + +/** + * Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office + * counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton + * owns the list as a writable RemoteData signal, delete removes the row synchronously + * (optimistic) and rolls back on error. Admin delete removes any case (any owner, + * submitted or not — the server enforces the capability). + */ +@Injectable({ providedIn: 'root' }) +export class AdminCasesStore { + private adapter = inject(ApplicationsAdapter); + + private state = signal>({ tag: 'Loading' }); + readonly cases = this.state.asReadonly(); + + /** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the + last-good value on a resync (only shows Loading on the first load). */ + async load() { + if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' }); + try { + const parsed = parseApplications(await this.adapter.listAll()); + this.state.set( + parsed.ok + ? { tag: 'Success', value: parsed.value } + : { tag: 'Failure', error: new Error(parsed.error) }, + ); + } catch (e) { + this.state.set({ tag: 'Failure', error: e as Error }); + } + } + + reload() { + void this.load(); + } + + /** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error. */ + async delete(id: string) { + const before = this.state(); + if (before.tag === 'Success') { + this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) }); + } + try { + await this.adapter.deleteAny(id); + } catch { + this.state.set(before); // roll back: the row reappears + } + } +} diff --git a/src/app/registratie/domain/aanvraag.ts b/src/app/registratie/domain/aanvraag.ts index d42e5fa..8d38baa 100644 --- a/src/app/registratie/domain/aanvraag.ts +++ b/src/app/registratie/domain/aanvraag.ts @@ -24,6 +24,9 @@ export interface Aanvraag { createdAt: string; updatedAt: string; submittedAt?: string; + /** The case owner (a BSN). Only populated by the admin cross-owner list (WP-36); + the user's own list leaves it undefined. */ + owner?: string; } /** Detail adds the opaque wizard snapshot used to resume a Concept. */ diff --git a/src/app/registratie/infrastructure/applications.adapter.ts b/src/app/registratie/infrastructure/applications.adapter.ts index 5dc339a..0ae4ebe 100644 --- a/src/app/registratie/infrastructure/applications.adapter.ts +++ b/src/app/registratie/infrastructure/applications.adapter.ts @@ -32,6 +32,16 @@ export class ApplicationsAdapter { return this.client.applicationsAll(); } + /** Admin: every case across all owners (WP-36; `cases:manage`). Parsed at the boundary. */ + listAll(): Promise { + return this.client.casesAll(); + } + + /** Admin: delete ANY case (any owner, submitted or not — WP-36). */ + deleteAny(id: string): Promise { + return this.client.cases(id); + } + detail(id: string): Promise { return this.client.applicationsGET(id); } @@ -100,6 +110,7 @@ function parseCommon(dto: ApplicationSummaryDto): Result { createdAt: dto.createdAt, updatedAt: dto.updatedAt, submittedAt: dto.submittedAt, + owner: dto.owner, // only present on the admin cross-owner list (WP-36) }); } diff --git a/src/app/registratie/ui/admin-cases.page.ts b/src/app/registratie/ui/admin-cases.page.ts new file mode 100644 index 0000000..aa088a4 --- /dev/null +++ b/src/app/registratie/ui/admin-cases.page.ts @@ -0,0 +1,129 @@ +import { Component, computed, effect, inject } from '@angular/core'; +import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { ButtonComponent } from '@shared/ui/button/button.component'; +import { DataBlockComponent } from '@shared/ui/data-block/data-block.component'; +import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; +import { ASYNC } from '@shared/ui/async/async.component'; +import { AccessStore } from '@shared/application/access.store'; +import { formatDatumNl } from '@shared/kernel/datum'; +import { Aanvraag } from '@registratie/domain/aanvraag'; +import { TYPE_LABELS, statusLabel, referentie } from '@registratie/domain/aanvraag-view'; +import { AdminCasesStore } from '@registratie/application/admin-cases.store'; + +/** + * Admin page: every case across all owners, with an admin delete (WP-36). Lives in + * `registratie` (which owns the Aanvraag aggregate) — the back-office counterpart of the + * user's dashboard, reusing the same view labels + trust-boundary parse. Deny-by-default + * capability gate (`cases:manage`): a denial alert for non-admins, the list for admins. + * Delete is guarded by a native confirm — it is irreversible and may remove submitted cases. + */ +@Component({ + selector: 'app-admin-cases-page', + imports: [ + PageShellComponent, + AlertComponent, + ButtonComponent, + DataBlockComponent, + DataRowComponent, + ...ASYNC, + ], + styles: [ + ` + .case { + margin-block-end: var(--rhc-space-max-lg); + } + `, + ], + template: ` + + @if (!access.ready()) { + + } @else if (!canManage()) { + {{ deniedText }} + } @else { + + + {{ failedText }} + {{ retryText }} + + + @if (cases().length === 0) { + {{ emptyText }} + } @else { + @for (c of cases(); track c.id) { +
+ + @for (row of rows(c); track row.key) { +
+ } +
+ {{ + deleteText + }} +
+ } + } +
+
+ } +
+ `, +}) +export class AdminCasesPage { + protected store = inject(AdminCasesStore); + protected access = inject(AccessStore); + + protected canManage = computed(() => this.access.can('cases:manage')); + protected cases = computed(() => { + const rd = this.store.cases(); + return rd.tag === 'Success' ? rd.value : []; + }); + + protected heading = $localize`:@@adminCases.heading:Aanvragen beheren`; + protected intro = $localize`:@@adminCases.intro:Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt.`; + protected deniedText = $localize`:@@adminCases.denied:U hebt geen rechten om aanvragen te beheren.`; + protected failedText = $localize`:@@adminCases.failed:De aanvragen konden niet worden geladen.`; + protected emptyText = $localize`:@@adminCases.empty:Er zijn geen aanvragen.`; + protected retryText = $localize`:@@adminCases.retry:Opnieuw proberen`; + protected deleteText = $localize`:@@adminCases.delete:Verwijderen`; + + private ownerKey = $localize`:@@adminCases.owner:Eigenaar (BSN)`; + private statusKey = $localize`:@@adminCases.status:Status`; + private refKey = $localize`:@@adminCases.referentie:Referentie`; + private ingediendKey = $localize`:@@adminCases.ingediend:Ingediend op`; + + protected typeLabel = (c: Aanvraag) => TYPE_LABELS[c.type]; + + /** Key/value rows for one case (owner + lifecycle facts; the type is the block heading). */ + protected rows(c: Aanvraag): { key: string; value: string }[] { + return [ + { key: this.ownerKey, value: c.owner ?? '—' }, + { key: this.statusKey, value: statusLabel(c.status) }, + { key: this.refKey, value: referentie(c.status) || '—' }, + { key: this.ingediendKey, value: c.submittedAt ? formatDatumNl(c.submittedAt) : '—' }, + ]; + } + + private loadRequested = false; + constructor() { + // Load once the capability resolves to allowed (a 403 GET would be wasted otherwise). + // Depends only on canManage() + a plain flag — never the store model (WP-26 loop lesson). + effect(() => { + if (this.canManage() && !this.loadRequested) { + this.loadRequested = true; + void this.store.load(); + } + }); + } + + protected reload() { + void this.store.load(); + } + + /** Native confirm — no dialog component exists, and admin delete is irreversible. */ + protected confirmDelete(c: Aanvraag) { + const msg = $localize`:@@adminCases.confirm:Deze aanvraag definitief verwijderen?`; + if (confirm(msg)) void this.store.delete(c.id); + } +} diff --git a/src/app/shared/domain/capability.ts b/src/app/shared/domain/capability.ts index 86133e5..84ee405 100644 --- a/src/app/shared/domain/capability.ts +++ b/src/app/shared/domain/capability.ts @@ -3,4 +3,9 @@ * Server-resolved and opaque to the FE — never derived from a role client-side. */ export type Capability = - 'brief:approve' | 'brief:reject' | 'brief:send' | 'orgtemplate:edit' | 'stamdata:edit'; + | 'brief:approve' + | 'brief:reject' + | 'brief:send' + | 'orgtemplate:edit' + | 'stamdata:edit' + | 'cases:manage'; diff --git a/src/app/shared/infrastructure/api-client.ts b/src/app/shared/infrastructure/api-client.ts index 5a7ef29..43186d5 100644 --- a/src/app/shared/infrastructure/api-client.ts +++ b/src/app/shared/infrastructure/api-client.ts @@ -1032,6 +1032,94 @@ export class ApiClient { return Promise.resolve(null as any); } + /** + * @return OK + */ + casesAll(): Promise { + let url_ = this.baseUrl + "/api/v1/admin/cases"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processCasesAll(_response); + }); + } + + protected processCasesAll(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ApplicationSummaryDto[]; + return result200; + }); + } else if (status === 403) { + return response.text().then((_responseText) => { + let result403: any = null; + result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Forbidden", status, _responseText, _headers, result403); + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return No Content + */ + cases(id: string): Promise { + let url_ = this.baseUrl + "/api/v1/admin/cases/{id}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "DELETE", + headers: { + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processCases(_response); + }); + } + + protected processCases(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 204) { + return response.text().then((_responseText) => { + return; + }); + } else if (status === 403) { + return response.text().then((_responseText) => { + let result403: any = null; + result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Forbidden", status, _responseText, _headers, result403); + }); + } else if (status === 404) { + return response.text().then((_responseText) => { + return throwException("Not Found", status, _responseText, _headers); + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + /** * @return OK */ @@ -1674,6 +1762,7 @@ export interface ApplicationSummaryDto { createdAt?: string | undefined; updatedAt?: string | undefined; submittedAt?: string | undefined; + owner?: string | undefined; } export interface BriefDecisionsDto { diff --git a/src/app/shared/infrastructure/me.adapter.ts b/src/app/shared/infrastructure/me.adapter.ts index 24eeeef..834ce1c 100644 --- a/src/app/shared/infrastructure/me.adapter.ts +++ b/src/app/shared/infrastructure/me.adapter.ts @@ -9,6 +9,7 @@ const KNOWN: readonly Capability[] = [ 'brief:send', 'orgtemplate:edit', 'stamdata:edit', + 'cases:manage', ]; /** diff --git a/src/app/shared/infrastructure/role.interceptor.ts b/src/app/shared/infrastructure/role.interceptor.ts index e518348..400149e 100644 --- a/src/app/shared/infrastructure/role.interceptor.ts +++ b/src/app/shared/infrastructure/role.interceptor.ts @@ -12,6 +12,7 @@ import { currentRole } from './role'; const ROLE_AWARE = [ '/api/v1/brief', '/api/v1/admin/org-template', + '/api/v1/admin/cases', '/api/v1/stamdata', '/api/v1/me', ]; diff --git a/src/app/shared/layout/site-header/site-header.component.ts b/src/app/shared/layout/site-header/site-header.component.ts index 0c14415..a735aa1 100644 --- a/src/app/shared/layout/site-header/site-header.component.ts +++ b/src/app/shared/layout/site-header/site-header.component.ts @@ -33,6 +33,11 @@ const ADMIN_NAV_ITEMS: readonly (HeaderNavItem & { readonly cap: Capability })[] to: '/beheer/stamdata', cap: 'stamdata:edit', }, + { + label: $localize`:@@header.nav.zaken:Aanvragen`, + to: '/beheer/zaken', + cap: 'cases:manage', + }, ]; /** Organism: CIBG Huisstijl site header — logo block, robijn titlebar (breadcrumb + diff --git a/src/locale/messages.en.xlf b/src/locale/messages.en.xlf index 9a379b5..7615d7d 100644 --- a/src/locale/messages.en.xlf +++ b/src/locale/messages.en.xlf @@ -3638,6 +3638,58 @@ 27 + + Aanvragen + Cases + + + Aanvragen beheren + Manage cases + + + Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt. + All cases in the register. Deleting a case cannot be undone. + + + U hebt geen rechten om aanvragen te beheren. + You do not have permission to manage cases. + + + De aanvragen konden niet worden geladen. + The cases could not be loaded. + + + Er zijn geen aanvragen. + There are no cases. + + + Opnieuw proberen + Try again + + + Verwijderen + Delete + + + Eigenaar (BSN) + Owner (BSN) + + + Status + Status + + + Referentie + Reference + + + Ingediend op + Submitted on + + + Deze aanvraag definitief verwijderen? + Permanently delete this case? + Ongedaan maken diff --git a/src/locale/messages.xlf b/src/locale/messages.xlf index 5b20e4e..464df89 100644 --- a/src/locale/messages.xlf +++ b/src/locale/messages.xlf @@ -1794,6 +1794,90 @@ 95 + + Aanvragen beheren + + src/app/registratie/ui/admin-cases.page.ts + 83 + + + + Alle aanvragen in het register. Een aanvraag verwijderen kan niet ongedaan worden gemaakt. + + src/app/registratie/ui/admin-cases.page.ts + 84 + + + + U hebt geen rechten om aanvragen te beheren. + + src/app/registratie/ui/admin-cases.page.ts + 85 + + + + De aanvragen konden niet worden geladen. + + src/app/registratie/ui/admin-cases.page.ts + 86 + + + + Er zijn geen aanvragen. + + src/app/registratie/ui/admin-cases.page.ts + 87 + + + + Opnieuw proberen + + src/app/registratie/ui/admin-cases.page.ts + 88 + + + + Verwijderen + + src/app/registratie/ui/admin-cases.page.ts + 89 + + + + Eigenaar (BSN) + + src/app/registratie/ui/admin-cases.page.ts + 91 + + + + Status + + src/app/registratie/ui/admin-cases.page.ts + 92 + + + + Referentie + + src/app/registratie/ui/admin-cases.page.ts + 93 + + + + Ingediend op + + src/app/registratie/ui/admin-cases.page.ts + 94 + + + + Deze aanvraag definitief verwijderen? + + src/app/registratie/ui/admin-cases.page.ts + 126 + + Uw wijziging is ontvangen (referentie ). U ontvangt binnen 5 werkdagen bericht. @@ -2691,32 +2775,39 @@ 32 + + Aanvragen + + src/app/shared/layout/site-header/site-header.component.ts + 37 + + BIG-register src/app/shared/layout/site-header/site-header.component.ts - 70,71 + 75,76 Ministerie van Volksgezondheid, Welzijn en Sport src/app/shared/layout/site-header/site-header.component.ts - 72,74 + 77,79 Uitloggen src/app/shared/layout/site-header/site-header.component.ts - 94,95 + 99,100 Hoofdnavigatie src/app/shared/layout/site-header/site-header.component.ts - 102,103 + 107,108