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.