Skip to content

Flow: admin bet view + adjustment (admin read path)

Trace: 27c026c8a218446915a31d20ce7180f3 · Jaeger: http://localhost:16686/ · E2E: tests-e2e/tests/admin-bets.spec.ts Generated: 2026-04-16 · Author: prisma-otel-engineer · Services traced: ebit-api. Companion to the player-side history (dropbet-bet-history.md task #32) and the write-side pipeline (dropbet-bet-place.md task #31). Scope: the admin bet read path — POST /admin/bets, the deprecated POST /bets on the bo app, and the surface of admin-side bet adjustment (which, despite the task title, doesn't really exist — see §6 SF-028).

1. User-visible contract

One live admin endpoint, one dead one.

POST /admin/bets (api app, :4000) POST /bets (bo app, :4003 — deprecated)
Mounted at apps/api/src/bet/admin.bet.controller.ts:11-25 apps/bo/src/bet/bet-http.controller.ts:14-52
Auth PermissionGuard('user.bets.view') (JWT cookie + permission key + MFA) None at the HTTP layer; EventsGateway.sendEventWithAuth reads Authorization: Bearer … from the request directly
Throttle global bucket only global bucket only
Body FindManyBetsAdminQuery: { page, take ≤ 20, sortBy ∈ {CREATED_AT, MULTIPLIER, USD_AMOUNT, USD_PAYOUT}, sortOrder ∈ {asc,desc}, where?: BetsFilterDto } same DTO accepted but never reaches a handler — see §6 SF-026
Response PaginatedDto<BetDto>: { data, total, page, take, totalPages } 500 Internal server error after ~5 s timeout
Caching none — every hit runs Prisma n/a

BetDto (apps/api/src/bet/dto/bet.dto.ts:42-112) keeps payload @Exclude()d so seed/RNG envelopes never leave the database, but exposes everything the player-facing BetPublicDto hides: userId, roundId, gameId, errorDetails, commissionGgrUsdAmount, commissionGgrPercent, the settledAt timestamp, and a UserShortPrivateDto (carries id + isBanned, drops the MaskIfPrivate decorator the public version uses on username/vipLevel). gameIdentity is renamed to game on the wire via @Expose({ name: 'gameIdentity' }).

BetsFilterDto (find-many-bets.dto.ts:34-85) is the typed shield around query.where. Although the FE in ebit-admin-fe/src/queries/bets/index.ts:25-58 builds a Prisma-shaped object verbatim, only the whitelisted fields (gameId, multiplier, usdAmount, usdPayout, status, userId, createdAt, amount, payout, currencyId, gameIdentity) survive ValidationPipe({ whitelist: true }); arbitrary clauses are dropped. Each operator field (e.g. DecimalFilterDto.{lt,gt,lte,gte,equals}) is itself typed.

There is no admin "adjust / void / rollback bet" endpoint anywhere in apps/api/src/bet/ (see §6 SF-028). The only writable admin route in the bet module is POST /admin/bet-queue/retry (queue/admin.bet-queue.controller.ts:7-17) which re-enqueues failed-settlement bets through BetQueueProducer.retry — it does not change a bet's outcome, just re-runs the settlement pipeline that already exists.

2. Sequence diagram

sequenceDiagram
  participant U as Admin (admin-fe)
  participant MW as Express + PermissionGuard
  participant C as AdminBetController
  participant S as BetCrudService
  participant R as BetRepository
  participant PG as Postgres (Prisma)
  alt POST /admin/bets — happy path
    U->>MW: POST /admin/bets {page, take, sortBy, sortOrder, where}
    MW->>MW: cors, cookieParser, session, passport, json (~1 ms)
    MW->>MW: PermissionGuard('user.bets.view'):<br/>JWT verify → role/permission → MFA gate
    MW->>C: findMany(body)
    C->>S: findManyBetsAdmin(query)
    S->>R: findManyBetsAdmin(query)
    R->>PG: prisma.bet.findMany({where, include: BetDto.prismaInclude({withGame, withUser, withProvider}), take, skip, orderBy})
    PG-->>R: bet rows
    R->>PG: prisma.bet.count({where})
    PG-->>R: total
    R-->>S: new PaginatedDto(rows, total, query, BetDto)
    S-->>U: 200/201 PaginatedDto<BetDto>
  else POST /admin/bets — non-admin
    U->>MW: POST /admin/bets (player JWT)
    MW->>MW: PermissionGuard runs JWT verify → permission check fails
    MW-->>U: 403 INSUFFICIENT_PERMISSIONS
  else POST /bets on :4003 (deprecated bo route)
    U->>MW: POST /bets (bo app, Bearer token)
    MW->>C: BetHttpController.findMany
    C->>R: gateway.sendEventWithAuth(GATEWAY_API_EVENTS.Private.BetFindMany)
    R-x R: ClientProxy (Redis pubsub) — no @MessagePattern subscriber anywhere
    R-->>C: timeout(5_000) → GatewayErrorResult
    C-->>U: 500 Internal server error
  end

The captured trace 27c026c8… is the happy path: 38 spans, 28.86 ms total, single-service (ebit-api). Top spans by duration: POST /admin/bets (28.86 ms) → AdminBetController.findMany (24.66 ms) → findMany (11.26 ms, BetCrudService wrapper) → prisma:client:operation Bet.findMany (8.57 ms) → prisma:client:operation Bet.count (2.23 ms). The two Prisma ops run sequentially (await … await …), not under a Promise.all like the player-side findManyBets does — see §6 SF-025.

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.

flowchart TD
    %% Datastores
    pg[("Postgres<br/>Bet · User · GameIdentity · Provider")]
    rd[("Redis (cache)<br/>auth-session · user details · BullMQ bet_queue · ms transport")]

    %% Admin browser SPA
    subgraph adm["ebit-admin-fe (Vite + React 19)"]
        q["useBetsHistoryQuery<br/><i>queries/bets POST /admin/bets</i>"]
    end

    %% Primary api process
    subgraph api["ebit-api :4000"]
        ctrl["AdminBetController.findMany<br/><i>POST /admin/bets</i>"]
        perm["PermissionGuard<br/><i>'user.bets.view' + JWT + MFA</i>"]
        svc["BetCrudService.findManyBetsAdmin<br/><i>thin pass-through</i>"]
        repo["BetRepository.findManyBetsAdmin<br/><i>sequential findMany + count (SF-025)</i>"]
        qctrl["AdminBetQueueController.retry<br/><i>POST /admin/bet-queue/retry · Role.SuperAdmin</i>"]
        qprod["BetQueueProducer.retry<br/><i>BullMQ re-enqueue</i>"]
    end

    %% Dead deprecated bo process
    subgraph bo["ebit-api bo app :4003 (deprecated, dead)"]
        boctrl["BetHttpController<br/><i>POST /bets · no @UseGuards (SF-027)</i>"]
        gw["EventsGateway.sendEventWithAuth<br/><i>Bearer header + ClientProxy</i>"]
    end

    %% (1)-(7) Primary admin happy path
    q -- "(1) POST /admin/bets" --> ctrl
    ctrl -- "(2) @UseGuards" --> perm
    perm -- "(3) JWT / permission / MFA probes" --> rd
    ctrl -- "(4) findManyBetsAdmin(query)" --> svc
    svc -- "(5) pass-through" --> repo
    repo -- "(6) Bet.findMany" --> pg
    repo -- "(7) Bet.count (sequential, SF-025)" --> pg

    %% (8)-(9) Retry endpoint — only admin-side write
    qctrl -- "(8) retry()" --> qprod
    qprod -- "(9) BullMQ re-enqueue" --> rd

    %% (10)-(11) Deprecated bo route — dead
    boctrl -- "(10) sendEventWithAuth" --> gw
    gw -- "(11) Private.BetFindMany — no subscriber, 5s timeout (SF-026)" --> rd

    %% Style: datastores stand out
    classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
    class pg,rd db;

4. Per-step walkthrough

Section headers mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram. Captured trace 27c026c8… covers steps (1)–(7); steps (8)–(9) and (10)–(11) are separate flows referenced by other E2Es.

4.1 Step (1) — POST /admin/bets reaches AdminBetController

useBetsHistoryQuery (ebit-admin-fe/src/queries/bets/index.ts:25-58) POSTs the typed FindManyBetsAdminQuery body. The admin-fe builds the where clause Prisma-shaped, but ValidationPipe({whitelist:true}) on the api side drops anything outside the BetsFilterDto whitelist (find-many-bets.dto.ts:34-85), so arbitrary clauses are filtered before the handler runs.

admin.bet.controller.ts:19-24 is a one-liner that hands body: FindManyBetsAdminQuery straight to betCrudService.findManyBetsAdmin(body). No @Req, no userId injection — admin reads across all users.

4.2 Steps (2)–(3) — PermissionGuard('user.bets.view') (~3 ms)

apps/api/src/auth/guards/permission.guard.ts:11-49 extends AuthGuard('jwt'), so it first runs the JWT strategy (strategies/jwt.strategy.ts:46-47 reads req.cookies.access_token) which loads request.user with roles + permissions joined. The Redis probes from step (3) are visible in the trace as three cache reads (auth-session:…, user:details:<uid>, zscore online_users) plus an evalsha for the BullMQ session bump.

Then in order:

  1. SuperAdmin shortcut at line 24 — user.roles.some(r => r.role === Role.SuperAdmin) returns true immediately, skipping both the permission key check AND the MFA gate. Load-bearing for SF-029.
  2. Permission key check at line 28 — user.permissions.some(p => permissionKeys.includes(p.permissionKey)). For user.bets.view admins (non-SuperAdmin staff), the seed needs to grant this permission row.
  3. MFA gate at line 40 — throws USER_MFA_NOT_ENABLED if !user.mfaSecret AND the disable_otp feature flag is off. For non-SuperAdmin admins, MFA is therefore mandatory by default.

The E2E pins all three branches: SuperAdmin via admin-1@admin.com (passes), the seeded dropbet user via local@example.com (403 INSUFFICIENT_PERMISSIONS at the permission step, never reaches MFA).

4.3 Step (4) — AdminBetController.findManyBetCrudService (24.66 ms wrapper span)

The controller hands off to betCrudService.findManyBetsAdmin(body). The wrapper span dominates the trace (24.66 ms of the total 28.86 ms) because everything downstream — the service pass-through and both Prisma ops — is its child.

4.4 Step (5) — BetCrudService.findManyBetsAdminBetRepository (pass-through)

BetCrudService.findManyBetsAdmin (bet-crud.service.ts:26-30) is itself a pass-through to BetRepository.findManyBetsAdmin. The wrapper exists only so the service layer can grow detail/aggregation later without re-routing the controller. No DB calls at this step.

4.5 Steps (6)–(7) — Bet.findMany then Bet.count (sequential, SF-025)

bet.repository.ts:320-358. Three notable departures from findManyBets:

  1. No gameIdentity.type != SPORTSBOOK filter. The user-side method hard-codes that exclusion at bet.repository.ts:280-283; admin keeps everything. Admin is the only place a sportsbook bet is observable today (see SF-012 in dropbet-bet-history.md).
  2. Sequential findMany then count, not Promise.all like the player-side does (bet.repository.ts:301-310). On a fully populated DB those two queries can be issued in parallel — sequential adds the count latency on top of the findMany. The trace shows Bet.findMany 8.57 ms followed by Bet.count 2.23 ms — about 30 % wall-time on top. SF-025.
  3. BetDto.prismaInclude({withGame:true, withUser:true, withProvider:true}) instead of the user-side {withGame, withProvider}. The extra withUser:true joins User via UserShortPrivateDto.prismaSelect() so admin gets id + isBanned per row.

orderBy is the same shape as the user-side ternary but accepts MULTIPLIER and USD_PAYOUT in addition to CREATED_AT / USD_AMOUNT (see BetsAdminSortBy enum at find-many-bets.dto.ts:12-17). The repo composes via [query.sortBy ?? CREATED_AT] — an unrecognised sort key falls back to {} (no ordering), which would surface as nondeterministic results; ValidationPipe({whitelist:true}) should reject any unknown enum value before that, so the path is in practice unreachable.

new PaginatedDto(rows, count, query, BetDto) constructs the envelope; BetDto's @Evo.Expose({ ignoreFields: ['game'] }) then drops the duplicate game field that the @Expose({name:'gameIdentity'}) mirror introduces, leaving exactly one game block on the wire.

4.6 Steps (8)–(9) — AdminBetQueueController.retry — the only admin-side write

apps/api/src/bet/queue/admin.bet-queue.controller.ts:7-17. RolesGuard + @Roles(Role.SuperAdmin) (no MFA gate, since RolesGuard is independent of PermissionGuard). Body-less POST /admin/bet-queue/retry invokes BetQueueProducer.retry() which scans the bet_queue BullMQ for failed jobs and re-enqueues them. Out of scope for the captured 27c026c8… trace.

4.7 Steps (10)–(11) — Deprecated bo POST /bets (dead route, SF-026 + SF-027)

apps/bo/src/bet/bet-http.controller.ts:14-52 declares GET /bets and POST /bets (POST has the JSDoc @deprecated Use POST /admin/bets instead). The controller has no @UseGuards; instead it calls EventsGateway.sendEventWithAuth(EventMessage{ event: GATEWAY_API_EVENTS.Private.BetFindMany }). sendEventWithAuth (libs/gateway/src/events.gateway.ts:78-92) requires Authorization: Bearer … on the incoming HTTP request (cookies are ignored — the bo app does NOT pass the access_token cookie down). It then gateway.send(event, …) over a Redis-transport ClientProxy and waits with timeout(5_000).

No service in the workspace @MessagePatterns Private.BetFindMany. A Grep across apps/ finds zero handlers (the apps/api/src/bet/ tree has no @MessagePattern decorators at all; the only API-app subscriber matches are in fast-track/debug/). So every call to POST :4003/bets with a valid bearer waits the full 5 s and returns 500 Internal server error (the GatewayErrorResult is parsed by EventsGateway.sendEvent line 71 and rethrown as a GatewayError, which the global filter renders as a generic 500). The E2E asserts >= 4500 ms elapsed before the 500. Without a bearer, the gateway throws UnauthorizedException('Token is required') synchronously → 401.

5. Data model

Object R/W Fields touched Notes
Bet (api.prisma:635-675) R full row except payload (which is @Exclude()d in BetDto); where driven by typed BetsFilterDto The BTree index bets_user_id_created_at_index (userId, createdAt) covers the common where: {userId} ORDER BY createdAt DESC admin path. Other admin filters (multiplier, usdAmount range, createdAt window) fall back to the dedicated single-column indexes (@@index([multiplier]), @@index([settledAt]), @@index([createdAt])).
User R UserShortPrivateDto.prismaSelect()id, username, isPrivate, avatar, vipLevel, isBanned Admin-only: id + isBanned (dropped from public). Username/vipLevel are NOT masked even if the player set isPrivate=true.
GameIdentity (+ optional Provider) R slug, name, type, images, customImages (+ provider.{name,images,customImages}) Same projection as the player list; withProvider:true adds the provider block.
Redis (cache / queue) none on the read path Admin list is uncached. The bet:house-game-info:{betId} / bet:slot-game-info:{betId} Redis caches from dropbet-bet-history.md §2 are NOT used here because the admin endpoint returns the list view, not detail.
BullMQ bet_queue W (only via /admin/bet-queue/retry) failed-job re-enqueue See §4.5.

PaginatedDto envelope (libs/shared/src/api/dto/pagination.dto.ts) exposes { data, total, page, take, totalPages }. Pagination is offset-based via PaginationUtils.getSkip(query) = (page-1) * take.

6. Failure modes

  1. SF-025 — Admin list runs count and findMany sequentially. bet.repository.ts:339-350 uses two separate awaits where the player-side method (bet.repository.ts:301-310) wraps them in Promise.all. With the captured trace at findMany=8.57 ms + count=2.23 ms, sequential costs ~25 % wall-clock; on a 50 M-row table where count(*) is itself the bottleneck the gap widens. Trivially fixable — wrap them in Promise.all([this.prisma.bet.findMany(…), this.prisma.bet.count(…)]).
  2. SF-026 — Deprecated bo POST /bets is fully dead. BetHttpController.findManyGet/.findMany proxy to Private.BetFindMany over Redis ms transport, but no service @MessagePatterns that event (grep returns zero subscribers). Every call with a valid bearer hangs for the gateway's 5 s timeout and surfaces as 500 Internal server error — the E2E pins boRes.status() === 500 and elapsed >= 4500 ms so removal of the route trips loudly. Fix: drop the controller (the JSDoc already says @deprecated Use POST /admin/bets instead), or wire a real @MessagePattern(GATEWAY_API_EVENTS.Private.BetFindMany) listener on the api app that delegates to BetCrudService.findManyBetsAdmin.
  3. SF-027 — bo route has no HTTP-level guard. BetHttpController has no @UseGuards at all. Authentication only happens inside sendEventWithAuth, which requires Authorization: Bearer …. Any logged-in user (player or admin) with a valid JWT can trigger the 5 s hang and the 500 — there's no permission check before the ClientProxy.send. Combined with SF-026, this is a tiny but real DOS surface against the bo process: 100 concurrent calls hold 100 ClientProxy sockets open for 5 s each. Fix: tighten the HTTP-level guard OR make the gateway client error-fast on missing subscriber.
  4. SF-028 — There is no admin "adjust / void / rollback bet" endpoint. Despite the task title (#41), the entire apps/api/src/bet/ tree has zero write routes that mutate a settled bet's payout, status, or multiplier. The closest neighbours are BetQueueProducer.retry (re-runs failed-settlement jobs — same outcome, not a hand-edit) and POST /admin/users/balance (apps/api/src/user/admin.user.controller.ts, separate doc). Manual bet correction today goes via balance adjustment + ad-hoc DB write, not through the bet module. Document explicitly so a future "void this bet" feature isn't built on top of unguarded code.
  5. SF-029 — SuperAdmin role bypasses the MFA gate. permission.guard.ts:24 returns true on Role.SuperAdmin BEFORE the if (!user.mfaSecret) check at line 40. The seeded admin@admin.com (no mfaSecret) successfully calls POST /admin/bets — verified by smoke test outside the spec. Only non-SuperAdmin permission-keyed admins are forced to enable MFA. If "MFA mandatory for admins" is the intended policy, the SuperAdmin shortcut needs to move BELOW the MFA branch.
  6. SF-030 — No aggregate fields on the response. PaginatedDto returns the row slice + total count only. A "total wagered USD this week" or "house GGR for this filter" needs a separate endpoint or 20-row-paged client-side reduction. The dashboard uses /admin/dashboard-v2/query/bets-by-type (ebit-admin-fe/src/queries/dashboard/index.ts:45) for that — separate trace, separate doc.

7. Unresolved

  • No detail endpoint on the admin surface. AdminBetController exposes only findMany. To inspect one bet's payload (RNG envelope, deserialised slug params), admin-fe falls through to the public GET /bets/house-games/info/:betId — which is currently un-guarded (dropbet-bet-history.md §6 SF-008) so any caller can read it anyway. A guarded GET /admin/bets/:betId would close the leak; would need to gate on user.bets.view.
  • take ≤ 20 cap is not bumpable for admin. createPaginatedQuery enforces the same 20-row max as the player-side. CSV export of "all of user X's bets" therefore scrolls 20 at a time. Either accept this for OLTP and move bulk reads to a separate analytics export, or add a dedicated take ≤ 200 admin variant.
  • commissionGgr* are present but not validated. BetDto exposes commissionGgrUsdAmount / commissionGgrPercent as raw Decimals; values come straight from Bet.calculateSettledBet (bet.repository.ts:360-372). If the settlement worker writes a wrong commission (rounding bug, currency-rate drift), the admin list shows it untransformed. A > 100 % guard or a checksum span would catch this; left for the metrics task (#25).
  • No span on PermissionGuard. The captured trace shows the guard's three Redis cache probes (auth-session:…, user:details:<uid>, zscore online_users and one evalsha for the BullMQ session bump — same prefix as every authenticated request) but no span explicitly labelled PermissionGuard.canActivate. Adding one would let us alert on permission_denied rates per route. Wire alongside task #25.
  • bet_queue retry has no E2E coverage. Test #41 anchors the read path; nobody asserts that POST /admin/bet-queue/retry actually re-runs failed bets. Worth a small follow-up that injects a failed Bet.status=ERROR row and asserts it lands back in bet_queue.