feat(portal-bff): admin user-directory read endpoint (#141)
CI / check (push) Successful in 3m1s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m34s
CI / a11y (push) Successful in 2m35s
CI / perf (push) Successful in 5m7s

## 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.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #141
This commit was merged in pull request #141.
This commit is contained in:
2026-05-14 20:01:38 +02:00
parent aca9e8d155
commit 1df99cd800
10 changed files with 607 additions and 2 deletions
@@ -0,0 +1,73 @@
import { Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsISO8601,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
/**
* Query parameters for `GET /api/admin/users`. Mirrors the
* audit-viewer DTO's pagination posture per ADR-0020 §"User list" —
* offset/limit with hard caps, every filter optional. Bound from
* the URL via Nest's `@Query()` + global ValidationPipe so unknown
* keys are rejected by `forbidNonWhitelisted` before reaching the
* handler.
*
* Filters target the `public.users` directory (PR #140). The
* `username` filter is exact-prefix match (Prisma `startsWith`)
* because audit users typically search by exact known prefix —
* `jane.doe`, `admin`, etc. `displayName` filter uses
* case-insensitive contains since display names have natural
* variation in casing.
*/
export const MAX_LIMIT = 200;
export const DEFAULT_LIMIT = 50;
const ADMIN_USERS_AUDIENCES = ['workforce', 'customer'] as const;
type AudienceFilter = (typeof ADMIN_USERS_AUDIENCES)[number];
export class AdminUsersQueryDto {
/** Exact-prefix match on `username`. */
@IsOptional()
@IsString()
@MaxLength(128)
username?: string;
/** Case-insensitive `contains` match on `display_name`. */
@IsOptional()
@IsString()
@MaxLength(128)
displayName?: string;
@IsOptional()
@IsEnum(ADMIN_USERS_AUDIENCES)
audience?: AudienceFilter;
/** ISO-8601 lower bound on `last_seen_at` (inclusive). */
@IsOptional()
@IsISO8601()
lastSeenAtFrom?: string;
/** ISO-8601 upper bound on `last_seen_at` (exclusive). */
@IsOptional()
@IsISO8601()
lastSeenAtTo?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(MAX_LIMIT)
limit?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
}