Skip to content

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.ts Generated: 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 + set on USER_DETAILS_get(id) via ExtendedCacheClient, 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 via ProfileNotifierService.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

AdminNotesControllerNotesService.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.findManyUsersWithStatsUserRepository. 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

  1. Admin-fe cross-service trace is blocked by four integration bugs (memory project_admin_fe_auth_bugs): cookie-name mismatch (jwt_access_token vs access_token), missing @vercel/otel fallback in admin-fe/src/instrumentation.ts, absent propagateContextUrls, hard-coded API host. Admin-UI calls land in ebit-api with no traceparent, so every action emits a single-service trace. This doc drives the API directly — ebit-api spans are the full observable surface.
  2. /admin/user/admin-audit userId filter is surprising. UserAdminAuditRequestDto.userId filters by the admin actor (the user_id column on AdminActionLog), not by the target user referenced in the URL path. Empirically verified: ?userId=2 returns 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.
  3. banUser/unBanUser ignore the admin parameter (user.service.ts:383,400). Both wrappers take the actor but only persist {isBanned, banReason} on the target. There is no BanHistory table, no ban-duration, no record of who banned whom in the user table itself. The sole "admin X banned user Y" record is the AdminActionLog row from the interceptor's tap() — and safeLog swallows insert errors (try/catchEvoLogger.warn), so a transient DB error produces a ban with zero audit trail.
  4. Multi-ban route is a TODO. Comment at admin.user.controller.ts:148 declares // TODO implement rout for multi ban. Bulk moderation is not supported; a campaign to ban N accounts takes N sequential PATCH calls + N AdminActionLog rows.
  5. No idempotency on ban writes. 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. Accidental retries inflate the audit trail and double-kick any active websocket.

7. Unresolved

  • SuperAdmin mutations (PUT /admin/user/:id/balance · /roles · /permissions) layer OtpGuard on 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%.
  • BanHistory gap: if compliance needs per-event ban/unban history with actor + duration beyond AdminActionLog, a new table + service write is required.