Flow: admin user management (list · view · note · ban/unban · audit)¶
Trace IDs:
3ca54b0957f196c0794ef6240b5c544b(POST /admin/user/all · 41 spans · 21.9 ms) ·17cacfb01ce0b996cb46b380db656a19(PATCH /admin/user/:id/ban · 51 spans · 33.2 ms) Jaeger: http://localhost:16686/trace/3ca54b0957f196c0794ef6240b5c544b · http://localhost:16686/trace/17cacfb01ce0b996cb46b380db656a19 · E2E:tests-e2e/tests/admin-user-mgmt.spec.tsGenerated: 2026-04-16 · Services touched (trace):ebit-api(only — admin-fe SSR path is broken, see §6.1)
1. User-visible contract¶
Admin surface for looking up a user, annotating them, and toggling ban status. Exercised end-to-end via direct REST in the E2E spec because the admin-fe UI integration is blocked by four known bugs (memory project_admin_fe_auth_bugs); the doc focuses on the ebit-api side that IS fully traced.
Auth model: POST /auth/sign-in + POST /auth/verify-2fa sets access_token HttpOnly cookie. All /admin/user/* routes inherit JwtGuard from app.module.ts (global) and add PermissionGuard(<key>) for fine-grained control. Super-admin-only routes layer RolesGuard(Role.SuperAdmin) + OtpGuard (per-request OTP).
Endpoints (permission → handler):
- POST /admin/user/all (user.view · admin.user.controller.ts:57) — paginated list + search. Body carries {page, take, search, isBanned?, withRoles?, withPermissions?, filter?, sortBy?, sortOrder?}.
- GET /admin/user/:id (user.view · :248) — single-row detail incl. roles, permissions, balance, recent notes.
- GET /admin/user/:id/full/stats (user.view · :238) — totalWagered/totalPayouts/totalDeposits/totalWithdraws/ltv/ggr/rtp/avgBet/winRate/netDeposit/totalLoss.
- PATCH /admin/user/:id/ban + PATCH /admin/user/:id/unban (user.ban · :139,:152) — body {banReason}, returns full updated UserDto.
- PATCH /admin/user/:id (user.edit) — generic field updater.
- POST /admin/user-notes (user-note.edit · admin.notes.controller.ts:32) — attaches a UserNote row with {note, riskLevel}.
- GET /admin/user/admin-audit (admin-users.view-admin-audit · :99) — reads AdminActionLog rows written by the interceptor. Quirk: the userId query param filters by the admin actor (§6 #2), not the target.
- Super-admin + OTP only: PUT /admin/user/:id/balance, /:id/roles, /:id/permissions, /add-single-role, /revoke-single-role.
2. Sequence diagram¶
sequenceDiagram
participant A as admin (E2E via request.post)
participant API as ebit-api
participant PG as Postgres
participant R as Redis (cache)
participant PS as Redis (pub/sub — profile-notifier)
A->>API: POST /auth/sign-in {email,password}
API-->>A: 200 { token, requireMfa:true }
A->>API: POST /auth/verify-2fa {token, mfaCode}
API-->>A: 200 Set-Cookie access_token, refresh_token, socket_token
A->>API: POST /admin/user/all {search}
API->>R: throttler get · zscore · evalsha (sliding-window)
API->>PG: findManyUsersWithStats (count + rows)
API-->>A: 200 { data, total }
A->>API: GET /admin/user/:id
A->>API: GET /admin/user/:id/full/stats
A->>API: POST /admin/user-notes {userId, note, riskLevel}
API->>PG: INSERT UserNote
Note over API,PG: AdminLoggerInterceptor tap → INSERT AdminActionLog
A->>API: PATCH /admin/user/:id/ban {banReason}
API->>PG: UPDATE User SET isBanned=true, banReason, updatedAt
API->>R: updateUserInCache (set USER_DETAILS_*)
API->>PS: profileNotifier.onProfileUpdated (publish)
Note over API,PG: AdminLoggerInterceptor tap → INSERT AdminActionLog
A->>API: PATCH /admin/user/:id/unban {banReason}
API->>PG: UPDATE User SET isBanned=false, banReason, updatedAt
API->>R: updateUserInCache
API->>PS: profileNotifier.onProfileUpdated (publish)
A->>API: GET /admin/user/admin-audit?userId=<adminId>
API->>PG: SELECT AdminActionLog ORDER BY createdAt DESC
API-->>A: 200 { data: [ban, unban, note-create, ...] }
3. Component diagram¶
Edges are numbered in request-flow order. Section §4 below has the same numbers — each (N) on the diagram has its own §4.N subsection, so you can click straight through.
The canonical numbered backbone is PATCH /admin/user/:id/ban — the richest-side-effect write path (DB update + cache refresh + pub/sub kick + audit-log insert) and the only flow that exercises every component in the module. /unban reuses (1)–(7) byte-for-byte with isBanned=false. Read-only POST /admin/user/all, write POST /admin/user-notes, and read-only GET /admin/user/admin-audit are shown as numbered branches that share the same controller-stack prologue but fan out to different services; their step numbers continue the same sequence.
flowchart TD
%% Datastores
pg[("Postgres<br/>User · UserNote · AdminActionLog · Permission")]
rd[("Redis (cache)<br/>USER_DETAILS_* · throttler sliding-window")]
ps[("Redis (pub/sub)<br/>profile-notifier channel · consumed by rt")]
%% Admin caller — UI integration blocked by admin-fe bug cluster (memory: project_admin_fe_auth_bugs)
subgraph adm["admin-fe (UI path blocked — cross-service trace gap)"]
fe["E2E via request.post<br/><i>direct REST in admin-user-mgmt.spec.ts</i>"]
end
%% Primary api process
subgraph api["ebit-api :4000 (NestJS)"]
guards["JwtGuard + PermissionGuard<br/><i>'user.view' · 'user.ban' · 'user-note.edit'</i>"]
ctrl["AdminUserController<br/><i>banUser · unBanUser · v2GetAll · getOne</i>"]
nctrl["AdminNotesController<br/><i>POST /admin/user-notes</i>"]
usvc["UserService.banUser / unBanUser<br/><i>thin wrapper; admin arg unused (FM-Admin-user-mgmt-2)</i>"]
urepo["UserRepository.updateUniqueUser<br/><i>Prisma UPDATE + cache + notifier</i>"]
cache["ExtendedCacheClient<br/><i>USER_DETAILS_get(id) · TTL 60s</i>"]
notif["ProfileNotifierService<br/><i>onProfileUpdated → rt force-disconnect</i>"]
nsvc["NotesService.createNote<br/><i>Prisma UserNote CRUD</i>"]
audit["AdminLoggerInterceptor<br/><i>tap() → AdminActionLog · non-GET only</i>"]
end
%% (1)-(7) Canonical PATCH /ban backbone (§4.1–§4.5)
fe -- "(1) PATCH /admin/user/:id/ban" --> guards
guards -- "(2) banUser(id, dto, admin)" --> ctrl
ctrl -- "(3) banUser → updateUniqueUser" --> usvc
usvc -- "(4) Prisma UPDATE + select join" --> urepo
urepo -- "(5) UPDATE user / SELECT roles+permissions+balance+notes" --> pg
urepo -- "(6) updateUserInCache (set + expire)" --> cache
urepo -- "(7) onProfileUpdated publish" --> notif
notif -- "(7a) publish profile-notifier" --> ps
%% (8) Audit-log tap — fires on every non-GET admin mutation (§4.6)
ctrl -- "(8) tap() after handler resolves" --> audit
audit -- "(8a) INSERT AdminActionLog" --> pg
%% (9) Note-create variant (§4.7)
fe -. "(9) POST /admin/user-notes" .-> guards
nctrl -- "(9a) createNote" --> nsvc
nsvc -- "(9b) INSERT UserNote" --> pg
%% (10)-(11) Read-only list (§4.8)
fe -. "(10) POST /admin/user/all" .-> guards
ctrl -- "(11) findManyUsersWithStats (COUNT + SELECT)" --> pg
%% (12) Audit read (§4.9)
fe -. "(12) GET /admin/user/admin-audit" .-> guards
ctrl -- "(12a) SELECT AdminActionLog (interceptor skipped for GET)" --> pg
%% Style: datastores stand out
classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
class pg,rd,ps db;
Cache Redis (rd) is touched on every request by the throttler sliding-window (get + zscore + evalsha) but is not edge-labelled to keep the per-pair-edge rule clean; the throttler hops are called out in §4.1 Step (1).
4. Per-step walkthrough¶
Section headers below mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram. Captured traces: 17cacfb0…656a19 (51 spans, 33.2 ms) covers steps (1)–(8); 3ca54b09…5c544b (41 spans, 21.9 ms) covers steps (10)–(11); (9) and (12) are inferred from the same controller-stack prologue + the listed Prisma op.
4.1 Step (1) — PATCH /admin/user/:id/ban reaches the guard stack¶
Nest middleware prologue (query/json/cookie/cors/session — ~4 ms / 11 spans, identical for every /admin/* route). Throttler reads get (ioredis) at t≈6 ms then zscore + evalsha at t≈11 ms — the standard Lua sliding-window on cache Redis. Auth is the global JwtGuard (registered in app.module.ts) which reads access_token from the HttpOnly cookie set by POST /auth/verify-2fa.
4.2 Step (2) — PermissionGuard('user.ban') → AdminUserController.banUser¶
PermissionGuard (auth/guards/permission.guard.ts) layered on top of the global JWT guard runs the same SuperAdmin shortcut / permission-key / MFA branches as the admin-bets doc (admin-bets.md §4.2). For user.ban admins the seeded permission row must grant the key; SuperAdmin bypasses MFA (carried over from SF-029 in admin-bets). Controller method span AdminUserController.banUser opens at t≈4 ms (28.8 ms duration) — parent of every span below.
4.3 Step (3) — Controller → UserService.banUser¶
apps/api/src/user/user.service.ts:383. Thin wrapper: takes (userId, { banReason }, admin), ignores the admin parameter (FM-Admin-user-mgmt-2), constructs { isBanned: true, banReason } and forwards to UserRepository.updateUniqueUser. Span banUser opens at t≈13 ms.
4.4 Step (4)–(5) — UserRepository.updateUniqueUser writes Postgres¶
At t≈15 ms a single prisma:client:operation span fans out to six prisma:engine:db_query spans because the Prisma select pulls roles + permissions + balance + notes back in the same round-trip for the response DTO. /unban is the identical shape with isBanned=false. Neither branch writes a BanHistory/BanEvent row — the only per-event record is the AdminActionLog from Step (8) (see FM-Admin-user-mgmt-2). Re-PATCHing /ban on an already-banned user still issues the UPDATE, updates updatedAt, re-publishes to the profile-notifier channel, and writes another AdminActionLog row (FM-Admin-user-mgmt-4). Bulk moderation requires N sequential PATCH calls because there is no multi-ban route (FM-Admin-user-mgmt-3, admin.user.controller.ts:148 // TODO).
4.5 Step (6)–(7) — Cache refresh + profile-notifier publish¶
- (6) t≈28 ms:
expire+setonUSER_DETAILS_get(id)viaExtendedCacheClient, TTL 60 s. Subsequent reads see the new ban state immediately. - (7) t≈29 ms:
publish(ioredis, 4.7 ms) on the profile-notifier channel viaProfileNotifierService.onProfileUpdated. The rt service subscribes to that channel and force-disconnects the banned user's websocket (rt-side trace not captured here — separate process).
4.6 Step (8) — AdminLoggerInterceptor writes the audit row¶
t≈31 ms: an 8.7 ms prisma:client:operation (fans to 5 prisma:engine:db_query spans) for INSERT AdminActionLog. Fired from the interceptor's tap() after the handler resolves, so a thrown handler skips the audit write. safeLog wraps the insert in try/catch → EvoLogger.warn — see FM-Admin-user-mgmt-2 for why a transient Postgres blip leaves a ban with zero audit trail. The interceptor is registered globally on the admin module but skips GET (line 27 of the interceptor: if (method === 'GET' || …) return next.handle()), so reads of the log do not self-append (relevant for Step (12)).
4.7 Step (9) — POST /admin/user-notes variant¶
AdminNotesController → NotesService.createNote → one Prisma INSERT UserNote. No cache invalidation, no pub/sub. The response DTO eagerly selects createdBy + user short-DTOs so the UI can render avatars without a follow-up fetch. The same interceptor tap from Step (8) fires after the insert resolves, producing a paired AdminActionLog row.
4.8 Step (10)–(11) — POST /admin/user/all read path (trace 3ca54b09…5c544b)¶
21.9 ms / 41 spans. Same middleware + guard prologue as Step (1)–(2). AdminUserController.v2GetAll delegates to UserService.findManyUsersWithStats → UserRepository. Two prisma:client:operation spans (COUNT + SELECT) with a windowed LIMIT join; no pub/sub, no cache write — read-only. The interceptor's skip condition keys off method === 'GET' only — this route declares POST /all (body is the filter envelope) so it does generate an AdminActionLog row despite being semantically a read.
4.9 Step (12) — GET /admin/user/admin-audit¶
Paginated SELECT on AdminActionLog. GET methods skip the interceptor (see Step (8)) so reads of the log do not self-append. Quirk: UserAdminAuditRequestDto.userId filters by the admin actor (the user_id column on AdminActionLog), not the target user in the URL path — verified empirically: ?userId=2 (dropbet user) returns 0 rows; ?userId=12 (admin-1) returns the actions this spec performed. See FM-Admin-user-mgmt-1.
5. Data model¶
| Store | Key / table | R/W | Fields | Source |
|---|---|---|---|---|
| Postgres | user |
R+W | is_banned, ban_reason, updated_at (mutated by ban/unban); email, username, roles[], permissions[] via relations |
libs/_prisma/src/schema/api.prisma |
| Postgres | user_note |
W (create) · R (search by userId/text/username/email) | note, risk_level, user_id, created_by_id, created_at |
notes.dto.ts |
| Postgres | admin_action_log |
W (interceptor on non-GET /admin) · R (audit endpoint) | method, url, user_id (actor), ip_address, user_agent, request_body, response, status, duration_ms, created_at |
libs/modules/src/admin-logger/ |
| Redis (cache) | USER_DETAILS_get(id) |
R+W | cached UserDto, TTL 60 s |
user/const.ts |
| Redis (cache) | throttler:* |
R+W | sliding-window counter (Lua evalsha) | global Nest throttler |
| Redis (pub/sub) | profile-notifier channel | W | onProfileUpdated(UserDto) payload — consumed by rt to force-disconnect banned user |
user/profile-notifier.service.ts |
6. Failure modes¶
- Admin-fe cross-service trace is blocked by four integration bugs (memory
project_admin_fe_auth_bugs): cookie-name mismatch (jwt_access_tokenvsaccess_token), missing@vercel/otelfallback inadmin-fe/src/instrumentation.ts, absentpropagateContextUrls, hard-coded API host. Admin-UI calls land in ebit-api with notraceparent, so every action emits a single-service trace. This doc drives the API directly — ebit-api spans are the full observable surface. /admin/user/admin-audituserIdfilter is surprising.UserAdminAuditRequestDto.userIdfilters by the admin actor (theuser_idcolumn onAdminActionLog), not by the target user referenced in the URL path. Empirically verified:?userId=2returns 0 rows against the seeded dropbet user while?userId=12(admin-1) returns the three actions this spec performed. No documentation on the endpoint explains this — any tooling built against it must pass the admin id.banUser/unBanUserignore theadminparameter (user.service.ts:383,400). Both wrappers take the actor but only persist{isBanned, banReason}on the target. There is noBanHistorytable, no ban-duration, no record of who banned whom in theusertable itself. The sole "admin X banned user Y" record is theAdminActionLogrow from the interceptor'stap()— andsafeLogswallows insert errors (try/catch→EvoLogger.warn), so a transient DB error produces a ban with zero audit trail.- Multi-ban route is a TODO. Comment at
admin.user.controller.ts:148declares// TODO implement rout for multi ban. Bulk moderation is not supported; a campaign to ban N accounts takes N sequential PATCH calls + NAdminActionLogrows. - No idempotency on ban writes. Re-PATCHing
/banon an already-banned user still issues the UPDATE, updatesupdatedAt, re-publishes to the profile-notifier channel, and writes anotherAdminActionLogrow. Accidental retries inflate the audit trail and double-kick any active websocket.
7. Unresolved¶
- SuperAdmin mutations (
PUT /admin/user/:id/balance · /roles · /permissions) layerOtpGuardon top and are not exercised here — belong in a SuperAdmin-flow doc. - admin-fe bug cluster (#22 follow-ups) tracks fixing the four cookie/OTel issues; until then cross-service admin-UI trace coverage stays at 0%.
BanHistorygap: if compliance needs per-event ban/unban history with actor + duration beyondAdminActionLog, a new table + service write is required.