feat(portal-bff): admin user-directory read endpoint #141
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff-admin-users-endpoint"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.usersdirectory populated at sign-in by PR #140. The SPA viewer screen lands in the final PR of the chantier.What lands
AdminUsersQueryDtoMirrors
AdminAuditQueryDto's posture — filters all optional, every unknown query key rejected byforbidNonWhitelisted, limit capped at 200 / default 50.usernamestartsWith).displayNamecontains(display names vary in casing).audienceworkforce|customer.lastSeenAtFromlastSeenAtTolimitoffsetAdminUsersReaderPrisma typed client against
public.users— noSET LOCAL ROLEdance becausepublic.usershas no role-based privilege gate. The trust boundary is the controller's@RequireAdminguard.last_seen_at DESC, oid ASC. The second clause is a deterministic tie-breaker for pagination during sign-in bursts that share a timestamp.totalreported to the SPA matches what's on the page even under a concurrent sign-in landing between the two queries.AdminUsersControllerGET /api/admin/users,@RequireAdmin()at the class level. Forwards the validated DTO toAdminUsersReader, then emitsadmin.users.querywith{ filters, resultCount }— the fishing-expedition deterrent (mirror ofadmin.audit.queryfrom PR #132).AuditWriter.adminUsersQuery()New typed method +
AdminUsersQueryInputtype. Sameoutcome=success/ payload shape asadminAuditQuery— two distinct event types so a reviewer can pivot directly oneventTypewithout parsing payload.Notes for the reviewer
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.usersqueries don't needSET LOCAL ROLEbecause 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 ausers_readerrole + the same SET-LOCAL pattern AuditReader uses.audit.eventsonactor_id_hashis deferred. The salted hash is computable on the fly viaHashUserIdService, so adding it later is a service-level change — no schema migration required.actor_id_hashis deliberately NOT stored onpublic.users(per ADR-0013's invariant — the salt stays inside the audit module).Test plan
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).pnpm exec nx affected -t format:check lint test build --base=origin/main— clean.startsWith, displayName case-insensitivecontains, audience exact match, lastSeenAtgte/ltcomposition.curl --cookie 'portal_admin_session=...' /api/admin/users?username=jane&limit=10returns the matching subset and a newadmin.users.queryaudit row lands.What's next
The chantier's final PR:
/usersscreen — SPA viewer with filter form + table + pagination. Same shape as the/auditpage (PR #136): signal-driven state, color-coded badges for audience, ISO timestamps formatted locale-side, status states. Will graduate the sidebar entry fromaria-disabled"Soon" badge to a live link.