Flow: admin bet view + adjustment (admin read path)¶
Trace:
27c026c8a218446915a31d20ce7180f3· Jaeger: http://localhost:16686/ · E2E:tests-e2e/tests/admin-bets.spec.tsGenerated: 2026-04-16 · Author: prisma-otel-engineer · Services traced:ebit-api. Companion to the player-side history (dropbet-bet-history.mdtask #32) and the write-side pipeline (dropbet-bet-place.mdtask #31). Scope: the admin bet read path —POST /admin/bets, the deprecatedPOST /betson 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:
- SuperAdmin shortcut at line 24 —
user.roles.some(r => r.role === Role.SuperAdmin)returnstrueimmediately, skipping both the permission key check AND the MFA gate. Load-bearing for SF-029. - Permission key check at line 28 —
user.permissions.some(p => permissionKeys.includes(p.permissionKey)). Foruser.bets.viewadmins (non-SuperAdmin staff), the seed needs to grant this permission row. - MFA gate at line 40 — throws
USER_MFA_NOT_ENABLEDif!user.mfaSecretAND thedisable_otpfeature 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.findMany → BetCrudService (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.findManyBetsAdmin → BetRepository (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:
- No
gameIdentity.type != SPORTSBOOKfilter. The user-side method hard-codes that exclusion atbet.repository.ts:280-283; admin keeps everything. Admin is the only place a sportsbook bet is observable today (see SF-012 indropbet-bet-history.md). - Sequential
findManythencount, notPromise.alllike the player-side does (bet.repository.ts:301-310). On a fully populated DB those two queries can be issued in parallel — sequential adds thecountlatency on top of thefindMany. The trace showsBet.findMany8.57 ms followed byBet.count2.23 ms — about 30 % wall-time on top. SF-025. BetDto.prismaInclude({withGame:true, withUser:true, withProvider:true})instead of the user-side{withGame, withProvider}. The extrawithUser:truejoinsUserviaUserShortPrivateDto.prismaSelect()so admin getsid+isBannedper 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¶
- SF-025 — Admin list runs
countandfindManysequentially.bet.repository.ts:339-350uses two separateawaits where the player-side method (bet.repository.ts:301-310) wraps them inPromise.all. With the captured trace atfindMany=8.57 ms+count=2.23 ms, sequential costs ~25 % wall-clock; on a 50 M-row table wherecount(*)is itself the bottleneck the gap widens. Trivially fixable — wrap them inPromise.all([this.prisma.bet.findMany(…), this.prisma.bet.count(…)]). - SF-026 — Deprecated bo
POST /betsis fully dead.BetHttpController.findManyGet/.findManyproxy toPrivate.BetFindManyover 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 stimeoutand surfaces as500 Internal server error— the E2E pinsboRes.status() === 500andelapsed >= 4500 msso 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 toBetCrudService.findManyBetsAdmin. - SF-027 — bo route has no HTTP-level guard.
BetHttpControllerhas no@UseGuardsat all. Authentication only happens insidesendEventWithAuth, which requiresAuthorization: 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 theClientProxy.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. - 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'spayout,status, ormultiplier. The closest neighbours areBetQueueProducer.retry(re-runs failed-settlement jobs — same outcome, not a hand-edit) andPOST /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. - SF-029 — SuperAdmin role bypasses the MFA gate.
permission.guard.ts:24returnstrueonRole.SuperAdminBEFORE theif (!user.mfaSecret)check at line 40. The seededadmin@admin.com(nomfaSecret) successfully callsPOST /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. - SF-030 — No aggregate fields on the response.
PaginatedDtoreturns the row slice +totalcount 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.
AdminBetControllerexposes onlyfindMany. To inspect one bet'spayload(RNG envelope, deserialised slug params), admin-fe falls through to the publicGET /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 guardedGET /admin/bets/:betIdwould close the leak; would need to gate onuser.bets.view. take ≤ 20cap is not bumpable for admin.createPaginatedQueryenforces 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 dedicatedtake ≤ 200admin variant.commissionGgr*are present but not validated.BetDtoexposescommissionGgrUsdAmount/commissionGgrPercentas rawDecimals; values come straight fromBet.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_usersand oneevalshafor the BullMQ session bump — same prefix as every authenticated request) but no span explicitly labelledPermissionGuard.canActivate. Adding one would let us alert onpermission_deniedrates per route. Wire alongside task #25. bet_queue retryhas no E2E coverage. Test #41 anchors the read path; nobody asserts thatPOST /admin/bet-queue/retryactually re-runs failed bets. Worth a small follow-up that injects a failedBet.status=ERRORrow and asserts it lands back inbet_queue.