feat(portal-bff): admin user-directory read endpoint #141

Merged
julien merged 1 commits from feat/portal-bff-admin-users-endpoint into main 2026-05-14 20:01:39 +02:00
Owner

Summary

Second PR of the portal-admin User-list chantier per ADR-0020 §"v1 scope — User list (read-only)". Ships the read side: paginated, filterable HTTP endpoint that queries the public.users directory populated at sign-in by PR #140. The SPA viewer screen lands in the final PR of the chantier.

What lands

AdminUsersQueryDto

Mirrors AdminAuditQueryDto's posture — filters all optional, every unknown query key rejected by forbidNonWhitelisted, limit capped at 200 / default 50.

Filter Type Notes
username string ≤128 Exact-prefix match (Prisma startsWith).
displayName string ≤128 Case-insensitive contains (display names vary in casing).
audience enum workforce | customer.
lastSeenAtFrom ISO-8601 Inclusive lower bound.
lastSeenAtTo ISO-8601 Exclusive upper bound.
limit int 1..200 Default 50.
offset int ≥0 Default 0.

AdminUsersReader

Prisma typed client against public.usersno SET LOCAL ROLE dance because public.users has no role-based privilege gate. The trust boundary is the controller's @RequireAdmin guard.

  • Order: last_seen_at DESC, oid ASC. The second clause is a deterministic tie-breaker for pagination during sign-in bursts that share a timestamp.
  • COUNT + SELECT in one Prisma transaction so the total reported to the SPA matches what's on the page even under a concurrent sign-in landing between the two queries.
  • Hard cap on limit at 200 even when a caller bypasses the DTO — defense in depth on the BFF's event loop.

AdminUsersController

GET /api/admin/users, @RequireAdmin() at the class level. Forwards the validated DTO to AdminUsersReader, then emits admin.users.query with { filters, resultCount } — the fishing-expedition deterrent (mirror of admin.audit.query from PR #132).

AuditWriter.adminUsersQuery()

New typed method + AdminUsersQueryInput type. Same outcome=success / payload shape as adminAuditQuery — two distinct event types so a reviewer can pivot directly on eventType without parsing payload.

Notes for the reviewer

  • The shape mirrors AdminAuditController (#132) deliberately. Future admin read endpoints (CMS pages, menu items if they grow read filters) should adopt the same shape — keeps the SPA's data-flow pattern consistent across modules.
  • public.users queries don't need SET LOCAL ROLE because there's no append-only / read-only role split on the table. That's a deliberate v1 simplification; if the security review later asks for stricter isolation we'd add a users_reader role + the same SET-LOCAL pattern AuditReader uses.
  • The future "sign-in counts" join from audit.events on actor_id_hash is deferred. The salted hash is computable on the fly via HashUserIdService, so adding it later is a service-level change — no schema migration required.
  • The actor_id_hash is deliberately NOT stored on public.users (per ADR-0013's invariant — the salt stays inside the audit module).

Test plan

  • pnpm nx test portal-bff391 specs pass (was 365; +26 covering DTO validation, reader transaction shape + filter forwarding + pagination defaults + cap, controller path, audit typed method).
  • pnpm exec nx affected -t format:check lint test build --base=origin/main — clean.
  • Prisma transaction shape verified: COUNT + SELECT both fire exactly once per call; tuple-return contract honoured.
  • Filter projections verified per filter: username startsWith, displayName case-insensitive contains, audience exact match, lastSeenAt gte/lt composition.
  • e2e — pending the admin SPA viewer screen + a real admin session. Once both exist: curl --cookie 'portal_admin_session=...' /api/admin/users?username=jane&limit=10 returns the matching subset and a new admin.users.query audit row lands.

What's next

The chantier's final PR:

  • portal-admin /users screen — SPA viewer with filter form + table + pagination. Same shape as the /audit page (PR #136): signal-driven state, color-coded badges for audience, ISO timestamps formatted locale-side, status states. Will graduate the sidebar entry from aria-disabled "Soon" badge to a live link.
## Summary Second PR of the **portal-admin User-list chantier** per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"v1 scope — User list (read-only)". Ships the **read side**: paginated, filterable HTTP endpoint that queries the `public.users` directory populated at sign-in by PR #140. The SPA viewer screen lands in the final PR of the chantier. ## What lands ### [`AdminUsersQueryDto`](apps/portal-bff/src/admin/users-query.dto.ts) Mirrors `AdminAuditQueryDto`'s posture — filters all optional, every unknown query key rejected by `forbidNonWhitelisted`, limit capped at **200** / default **50**. | Filter | Type | Notes | | --- | --- | --- | | `username` | string ≤128 | Exact-prefix match (Prisma `startsWith`). | | `displayName` | string ≤128 | Case-insensitive `contains` (display names vary in casing). | | `audience` | enum | `workforce` \| `customer`. | | `lastSeenAtFrom` | ISO-8601 | Inclusive lower bound. | | `lastSeenAtTo` | ISO-8601 | Exclusive upper bound. | | `limit` | int 1..200 | Default **50**. | | `offset` | int ≥0 | Default **0**. | ### [`AdminUsersReader`](apps/portal-bff/src/admin/admin-users-reader.service.ts) Prisma typed client against `public.users` — **no `SET LOCAL ROLE`** dance because `public.users` has no role-based privilege gate. The trust boundary is the controller's `@RequireAdmin` guard. - **Order**: `last_seen_at DESC, oid ASC`. The second clause is a deterministic tie-breaker for pagination during sign-in bursts that share a timestamp. - **COUNT + SELECT in one Prisma transaction** so the `total` reported to the SPA matches what's on the page even under a concurrent sign-in landing between the two queries. - **Hard cap on limit** at 200 even when a caller bypasses the DTO — defense in depth on the BFF's event loop. ### [`AdminUsersController`](apps/portal-bff/src/admin/admin-users.controller.ts) `GET /api/admin/users`, `@RequireAdmin()` at the class level. Forwards the validated DTO to `AdminUsersReader`, then emits `admin.users.query` with `{ filters, resultCount }` — the fishing-expedition deterrent (mirror of `admin.audit.query` from PR #132). ### [`AuditWriter.adminUsersQuery()`](apps/portal-bff/src/audit/audit.service.ts) New typed method + `AdminUsersQueryInput` type. Same `outcome=success` / payload shape as `adminAuditQuery` — two distinct event types so a reviewer can pivot directly on `eventType` without parsing payload. ## Notes for the reviewer - The shape mirrors `AdminAuditController` (#132) deliberately. Future admin read endpoints (CMS pages, menu items if they grow read filters) should adopt the same shape — keeps the SPA's data-flow pattern consistent across modules. - `public.users` queries don't need `SET LOCAL ROLE` because there's no append-only / read-only role split on the table. That's a deliberate v1 simplification; if the security review later asks for stricter isolation we'd add a `users_reader` role + the same SET-LOCAL pattern AuditReader uses. - The future "sign-in counts" join from `audit.events` on `actor_id_hash` is **deferred**. The salted hash is computable on the fly via `HashUserIdService`, so adding it later is a service-level change — no schema migration required. - The `actor_id_hash` is deliberately **NOT** stored on `public.users` (per ADR-0013's invariant — the salt stays inside the audit module). ## Test plan - [x] `pnpm nx test portal-bff` — **391 specs pass** (was 365; +26 covering DTO validation, reader transaction shape + filter forwarding + pagination defaults + cap, controller path, audit typed method). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Prisma transaction shape verified: COUNT + SELECT both fire exactly once per call; tuple-return contract honoured. - [x] Filter projections verified per filter: username `startsWith`, displayName case-insensitive `contains`, audience exact match, lastSeenAt `gte/lt` composition. - [ ] e2e — pending the admin SPA viewer screen + a real admin session. Once both exist: `curl --cookie 'portal_admin_session=...' /api/admin/users?username=jane&limit=10` returns the matching subset and a new `admin.users.query` audit row lands. ## What's next The chantier's final PR: - **portal-admin `/users` screen** — SPA viewer with filter form + table + pagination. Same shape as the `/audit` page (PR #136): signal-driven state, color-coded badges for audience, ISO timestamps formatted locale-side, status states. Will graduate the sidebar entry from `aria-disabled` "Soon" badge to a live link.
julien added 1 commit 2026-05-14 19:58:52 +02:00
feat(portal-bff): admin user-directory read endpoint
CI / scan (pull_request) Successful in 2m29s
CI / commits (pull_request) Successful in 2m45s
CI / check (pull_request) Successful in 2m59s
CI / a11y (pull_request) Successful in 2m27s
CI / perf (pull_request) Successful in 7m2s
0817520e49
Second PR of the portal-admin User-list chantier per ADR-0020 §"v1
scope — User list (read-only)". Ships the read side: paginated,
filterable HTTP endpoint that queries the `public.users` directory
populated at sign-in by PR #140. The SPA viewer screen lands in the
final PR of the chantier.

What lands

- AdminUsersQueryDto (admin/users-query.dto.ts): mirrors
  AdminAuditQueryDto's posture — filters all optional, every
  unknown key rejected by `forbidNonWhitelisted`, limit capped at
  MAX_LIMIT (200) / default 50. Filters: username (exact prefix),
  displayName (case-insensitive contains), audience (workforce |
  customer enum), lastSeenAtFrom/To (ISO-8601).

- AdminUsersReader (admin/admin-users-reader.service.ts): Prisma
  typed client against `public.users` — no `SET LOCAL ROLE` dance
  because `public.users` has no role-based privilege gate; the
  trust boundary is the controller's @RequireAdmin guard. Order:
  `last_seen_at DESC, oid ASC` (the second clause is a deterministic
  tie-breaker for pagination during sign-in bursts that share a
  timestamp). COUNT + SELECT run in a single Prisma transaction so
  the `total` reported to the SPA matches what's on the page even
  under a concurrent sign-in.

- AdminUsersController (admin/admin-users.controller.ts):
  GET /api/admin/users, @RequireAdmin at the class level, forwards
  the validated DTO to AdminUsersReader, then emits
  admin.users.query with { filters, resultCount } as the
  fishing-expedition deterrent (mirror of admin.audit.query from
  PR #132).

- AuditWriter.adminUsersQuery() typed method + AdminUsersQueryInput
  type. Same outcome=success / payload shape as adminAuditQuery —
  two distinct event types so a reviewer can pivot directly on
  eventType without parsing payload.

Tests: +18 specs (DTO validation 9, reader 9 covering COUNT+SELECT
ordering, filter forwarding, default/cap on limit, range filter
composition; controller 6; audit typed method 2). All 391 BFF
specs pass.

Out of scope (next PR of the chantier):

- portal-admin /users screen — the SPA viewer with filter form +
  table + pagination, mirroring the /audit page that the audit
  viewer PR shipped.
- Sign-in counts joined from `audit.events` on `actor_id_hash`
  (computed via HashUserIdService on the fly). Deferred until the
  v1 list ships and the admin demand makes itself known.
julien merged commit 1df99cd800 into main 2026-05-14 20:01:39 +02:00
julien deleted branch feat/portal-bff-admin-users-endpoint 2026-05-14 20:01:41 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#141