feat(portal-bff): audit-stats endpoint — server-side aggregations with redis cache #173
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff-audit-stats-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
PR 1 of the tabs + full-result-charts chantier. New BFF endpoint
GET /api/admin/audit/statsthat computes the three chart aggregations server-side over the full filtered set (not the paginated slice the SPA currently feeds the charts with).What lands
New route —
GET /api/admin/audit/statsSame filter shape as the existing
GET /api/admin/auditminus pagination — the stats endpoint always aggregates the whole filtered set.@RequireAdmingated (per ADR-0020). Time bound respects the filters strictly per the chantier brief: no filter → aggregates across the full audit retention (365 days per ADR-0013). The Redis cache below absorbs repeated heavy queries.New service —
AuditStatsReaderMirrors
AuditReader's posture:SET LOCAL ROLE audit_reader. SELECT-only onaudit.eventseven if the BFF's connection is otherwise privileged.GROUP BYqueries scoped by the sameWHEREclause:date_trunc('day', created_at)::date AS day, COUNT(*) GROUP BY dayoutcome::text, COUNT(*) GROUP BY outcomedate_trunc('day', created_at)::date AS day, event_type, COUNT(*) GROUP BY day, event_typeRedis cache — 5-minute TTL per filter-hash
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.New audit event —
admin.audit.stats.queryMirrors
admin.audit.queryin posture (every admin read is auditable per ADR-0020 §"Read actions ... to deter fishing expeditions") with two differences:event_typeso 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 byMAX_LIMIT=200).total(size of the aggregated set) instead ofresultCount— stats responses don't paginate, the value carries more "size of scan" signal.Light amendment — ADR-0013
Two additions:
admin.audit.stats.queryevent family.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
AskUserQuestionbefore 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
buildWhereinto a shared helper betweenAuditReaderandAuditStatsReader? Considered. The two readers' shapes diverge in non-trivial ways:AuditReaderaddsLIMIT/OFFSETparameters appended to the same parameter array,AuditStatsReaderruns three queries that all share the sameWHERE(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.windowDaysparameter is a smaller change than retrofitting one across the API.textcast onoutcomein the SQL? Prisma's Postgres enum types come back as JS strings already, but theoutcomecolumn carries a Postgres enum (audit.AuditOutcome). The explicit::textis defensive —$queryRawUnsafe's typing isn't enum-aware, and the cast keeps the projection unambiguous regardless of the driver's row-shape inference.Date.toISOString().slice(0, 10)?date_trunc('day', ...)::datereturns a Postgresdatethat node-postgres surfaces as a JSDateat UTC midnight. The defaulttoJSONserialises the full ISO timestamp with the timezone offset — which is not what the chart x-axis wants. Slicing toYYYY-MM-DDmatches the SPA's chart bucket convention exactly.actorIdHashaudit row for the stats endpoint? It's the same hash flow asadminAuditQuery— theactor.oidfrom the session goes throughHashUserIdServiceper ADR-0012's salt-based pseudonymisation. The same flow is exercised by the existingadminAuditQuerytests; the newadminAuditStatsQuerymethod just routes torecordEventwith a differenteventType.Test plan
pnpm nx test portal-bff— 414 specs pass (was 401; +13 new: 8AuditStatsReaderservice + 5 controllerstatsendpoint).pnpm nx run portal-bff:lint— clean.pnpm nx build portal-bff— clean (webpack).pnpm nx serve portal-bff, sign in to portal-admin withPortal.Admin:curl http://localhost:3000/api/admin/audit/stats --cookie-jar /tmp/adminreturns the three projections.admin.audit.stats.queryrow inaudit.eventsafter the call (SELECT * FROM audit.events WHERE event_type = 'admin.audit.stats.query' ORDER BY occurred_at DESC LIMIT 1)../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 frompage().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.PR 1 of the tabs + full-result charts chantier. New BFF endpoint that powers the upcoming "Charts" tab on portal-admin's /audit page with aggregations computed over the FULL filtered set rather than the paginated slice that currently feeds the charts. New route: GET /api/admin/audit/stats * Same query DTO shape as /audit (AdminAuditStatsQueryDto) minus pagination — stats endpoint always aggregates the whole filtered set. * Returns three projections: - dailyVolume: [{day: 'YYYY-MM-DD', count}] - outcomeBreakdown: [{outcome, count}] - eventTypeByDay: [{day, eventType, count}] + total (sum of dailyVolume.count) for the donut centre label. * Time bound: respects filters strictly (per chantier brief). No filter → aggregates across the full audit retention (365 days per ADR-0013). The Redis cache below absorbs repeated heavy queries. New service: AuditStatsReader * Same role-locking posture as AuditReader — every query inside a transaction that opens with SET LOCAL ROLE audit_reader. SELECT-only on audit.events even if the BFF connection is otherwise privileged. * Parameterised SQL only. Filter values flow through positional parameters, never concatenated into the SQL string. * Three GROUP BY queries scoped by the same WHERE clause as the list endpoint's buildWhere (kept structurally in sync by convention, not extracted yet because the two readers' pagination shapes differ). Redis cache: 5-min TTL per filters-hash * Cache key = `audit:stats:` + sha256(canonical-JSON of filters) truncated to 16 hex chars. Sorted-keys canonicalisation so the same filters in different argument orders map to the same key. * Cache writes are best-effort — Redis-write failure does not fail the response. The DB read already happened. * Spec covers: cache-hit path skips DB, cache-key stability across key ordering, write happens with EX 300, write failure tolerance. Audit event: admin.audit.stats.query * New typed AuditWriter method (mirrors adminAuditQuery but captures `total` instead of `resultCount` — stats responses don't paginate, the value carries more "size of scan" signal). * Emitted on every stats call per ADR-0020's read-deterrence posture. Distinct event_type from admin.audit.query so an auditor can spot "scanned aggregations" vs "paged through rows" — different observation signals. ADR-0013 amended (light): new "Reader endpoints" subsection documents the two-endpoint reader surface + Redis cache caveat. The events table grows four rows it was previously missing (admin.access_denied, admin.audit.query, admin.audit.stats.query, admin.users.query). Verification: * 414 portal-bff specs pass (was 401; +13: 8 service-level + 5 controller-level). * `pnpm nx lint test build --projects=portal-bff` — green. PR 2 (SPA) lands next: Tabs UX (Table / Charts) + replaces the client-side per-page computeds with consumption of this endpoint.