2cdeb7434109acc321cc5f60660e9a11f6201219
## Summary
PR 1 of the tabs + full-result-charts chantier. New BFF endpoint `GET /api/admin/audit/stats` that computes the three chart aggregations server-side over the **full filtered set** (not the paginated slice the SPA currently feeds the charts with).
```
PR 1 (this one) — BFF endpoint + Redis cache + audit event + ADR-0013 amendment.
PR 2 — SPA: Tabs UX (Table / Charts) + replace the per-page computeds
with calls to this endpoint.
```
## What lands
### New route — `GET /api/admin/audit/stats`
```ts
GET /api/admin/audit/stats?eventType=...&audience=...&outcome=...
&subjectPrefix=...&createdAtFrom=...&createdAtTo=...
&actorIdHash=...
→ {
dailyVolume: [{ day: 'YYYY-MM-DD', count }],
outcomeBreakdown: [{ outcome, count }],
eventTypeByDay: [{ day, eventType, count }],
total // sum of dailyVolume.count, drives the donut centre
}
```
Same filter shape as the existing `GET /api/admin/audit` minus pagination — the stats endpoint always aggregates the whole filtered set. `@RequireAdmin` gated (per [ADR-0020](docs/decisions/0020-portal-admin-app.md)). Time bound respects the filters strictly per the chantier brief: no filter → aggregates across the full audit retention (365 days per [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md)). The Redis cache below absorbs repeated heavy queries.
### New service — [`AuditStatsReader`](apps/portal-bff/src/admin/audit-stats.service.ts)
Mirrors `AuditReader`'s posture:
- Every query inside a transaction that opens with `SET LOCAL ROLE audit_reader`. SELECT-only on `audit.events` even if the BFF's connection is otherwise privileged.
- Parameterised SQL only. Filter values flow through positional parameters, never concatenated.
- Three `GROUP BY` queries scoped by the same `WHERE` clause:
- `date_trunc('day', created_at)::date AS day, COUNT(*) GROUP BY day`
- `outcome::text, COUNT(*) GROUP BY outcome`
- `date_trunc('day', created_at)::date AS day, event_type, COUNT(*) GROUP BY day, event_type`
### Redis cache — 5-minute TTL per filter-hash
- Cache key: `audit:stats:<sha256(canonical-JSON of filters), 16 hex chars>`. Sorted-keys canonicalisation so the same filters in different argument orders map to the same key.
- TTL: 300 s. Audit rows are append-only so past aggregations are stable; new events are continuously inserted, so admins see at most 5-minute-stale aggregations — acceptable for "approximate dashboard" usage, not for "did the last event just land" debugging (use the list endpoint for that).
- Cache writes are best-effort — a Redis-write failure does not fail the response. The DB read already happened; the next call rebuilds the cache.
- The cache *write* path is covered by spec; the cache-hit shortcut path is covered too (skips the DB transaction entirely).
### New audit event — `admin.audit.stats.query`
Mirrors `admin.audit.query` in posture (every admin read is auditable per ADR-0020 §"Read actions ... to deter fishing expeditions") with two differences:
- Distinct `event_type` so an auditor can spot "scanned aggregations" vs "paged through rows" — different observation signals (the stats endpoint can sweep millions of rows in one call; the list endpoint is bounded by `MAX_LIMIT=200`).
- Payload carries `total` (size of the aggregated set) instead of `resultCount` — stats responses don't paginate, the value carries more "size of scan" signal.
### Light amendment — [ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md)
Two additions:
- New **"Reader endpoints"** subsection that enumerates the two-endpoint reader surface (list + stats), documents the Redis-cache caveat, and points at the new `admin.audit.stats.query` event family.
- The "events emitted in v1" table grows four rows it was previously missing on `main`: `admin.access_denied`, `admin.audit.query`, `admin.audit.stats.query`, `admin.users.query`.
No supersession, no new ADR. The decision shape (server-side aggregation + Redis cache + new audit event family) was settled in chat via `AskUserQuestion` before the implementation started; recording it here keeps the ADR honest without spawning a full ADR-0024 for what's essentially an extension of ADR-0013's reader surface.
## Notes for the reviewer
- **Why not factor `buildWhere` into a shared helper between `AuditReader` and `AuditStatsReader`?** Considered. The two readers' shapes diverge in non-trivial ways: `AuditReader` adds `LIMIT/OFFSET` parameters appended to the same parameter array, `AuditStatsReader` runs three queries that all share the same `WHERE` (no further params). A shared helper would have to either expose both shapes or hand back the raw clauses + params for callers to assemble — at which point the abstraction earns its weight back. Two ~50 LOC copies today, extraction when a third reader lands or when the shape diverges further.
- **Why not cap the time window when no filter is provided?** Honest disclosure beats clever defaults. The list endpoint also returns "everything matching the filters" with no protective cap; the stats endpoint follows the same posture. The Redis cache absorbs the cost when the same heavy query lands repeatedly; an admin running unfiltered queries at high rate will see flat latency after the first call. If we later observe a real perf issue, a `windowDays` parameter is a smaller change than retrofitting one across the API.
- **Why a `text` cast on `outcome` in the SQL?** Prisma's Postgres enum types come back as JS strings already, but the `outcome` column carries a Postgres enum (`audit.AuditOutcome`). The explicit `::text` is defensive — `$queryRawUnsafe`'s typing isn't enum-aware, and the cast keeps the projection unambiguous regardless of the driver's row-shape inference.
- **Why does the date round-trip through `Date.toISOString().slice(0, 10)`?** `date_trunc('day', ...)::date` returns a Postgres `date` that node-postgres surfaces as a JS `Date` at UTC midnight. The default `toJSON` serialises the full ISO timestamp with the timezone offset — which is not what the chart x-axis wants. Slicing to `YYYY-MM-DD` matches the SPA's chart bucket convention exactly.
- **No mention of the `actorIdHash` audit row for the stats endpoint?** It's the same hash flow as `adminAuditQuery` — the `actor.oid` from the session goes through `HashUserIdService` per ADR-0012's salt-based pseudonymisation. The same flow is exercised by the existing `adminAuditQuery` tests; the new `adminAuditStatsQuery` method just routes to `recordEvent` with a different `eventType`.
## Test plan
- [x] `pnpm nx test portal-bff` — **414 specs pass** (was 401; +13 new: 8 `AuditStatsReader` service + 5 controller `stats` endpoint).
- [x] `pnpm nx run portal-bff:lint` — clean.
- [x] `pnpm nx build portal-bff` — clean (webpack).
- [ ] **Manual smoke** — `pnpm nx serve portal-bff`, sign in to portal-admin with `Portal.Admin`:
- `curl http://localhost:3000/api/admin/audit/stats --cookie-jar /tmp/admin` returns the three projections.
- Verify the `admin.audit.stats.query` row in `audit.events` after the call (`SELECT * FROM audit.events WHERE event_type = 'admin.audit.stats.query' ORDER BY occurred_at DESC LIMIT 1`).
- Hit the endpoint twice in quick succession with the same filters → second call shows < 5 ms latency (cache hit, no DB transaction).
- Hit it with different filters → first call hits DB, second cache, third with same filters → cache hit.
- Stop Redis (`./infra/local/dev.sh stop redis`), hit the endpoint → still succeeds (cache miss + write swallowed), comes back live from DB.
## What's next
PR 2 — SPA Tabs UX (Table / Charts) + replace `dailyVolume() / outcomeBreakdown() / dailyByEventType()` (currently computed from `page().items`) with calls to this endpoint. The three computeds become signals filled by the HTTP call; the chart components on the Charts tab consume them unchanged.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #173
Documentation index
This is the entry point to all project documentation. It is maintained automatically: any addition, rename, or removal of a .md file under docs/ must be reflected here in the same change.
Conventions
- Documentation is written in English.
- One topic per file. Group related files into a folder when there are three or more.
- Cross-reference with relative links so they keep working in GitHub, IDEs, and exported sites.
- For architectural decisions, do not add them here — they belong in decisions/ as MADR 4.0.0 ADRs.
Sections
Daily development
- development.md — repo layout, prerequisites, initial setup, daily commands, observability dev-loop (Jaeger UI, log ↔ trace correlation), dependency updates (Renovate), conventional commit cycle. Day-to-day reference for working on the project.
Architecture
- architecture.md — cross-cutting Mermaid diagrams: C4 system context, C4 containers, Nx module boundaries, CI/CD pipeline. Single-decision diagrams (auth sequence, ERD, etc.) live inline in their ADR.
Onboarding & environment
Setup guides for new contributors:
- setup/01-wsl-terminal-setup.md — modern WSL terminal (Zsh + Powerlevel10k + CLI tools)
- setup/02-dev-web-stack.md — Node via nvm, pnpm via corepack, Docker
- setup/03-angular-nx-monorepo.md — Angular + Nx monorepo bootstrap
Operations & runbooks
Empty — to be populated when we deploy.
Security, performance, accessibility
Empty — placeholders to be filled with rationale docs alongside their corresponding ADRs.
Description
Languages
TypeScript
85.3%
JavaScript
5.4%
SCSS
4.3%
HTML
3.9%
Shell
0.8%
Other
0.3%