Flow: dropbet bet history + detail (read path)¶
List trace:
ca1c99b52eacf2e3beda94b4333f8f46· Detail trace:ee3d303fc4da1f51dff065ea82efb333· Jaeger: http://localhost:16686/ · E2E:tests-e2e/tests/dropbet-bet-history.spec.tsGenerated: 2026-04-16 · Author: prisma-otel-engineer · Services traced:ebit-api. Companion doc to the write path (dropbet-bet-place.mdtask #31) and the per-game mechanics (dropbet-house-game.mdtask #35). Scope: the read side of the personal bet history — list pagination + per-bet detail (seed/multiplier/payload). Public live feeds (HighRollers,LatestBets, etc.) and the admin feed (POST /admin/bets) are out of scope and noted briefly in §7.
1. User-visible contract¶
Two endpoints, both mounted on BetController at apps/api/src/bet/bet.controller.ts:
| List | Detail (house game) | Detail (slot) | |
|---|---|---|---|
| Method · path | GET /bets |
GET /bets/house-games/info/:betId |
GET /bets/slots/info/:betId |
| Auth | @UseGuards(JwtGuard) (line 19) |
Guard commented out (line 31) | No guard (line 43) |
| Throttle | global bucket only | global bucket only | global bucket only |
| Query / params | FindManyBetsQuery: page, take ≤ 20, status (default SETTLED), sortBy ∈ {CREATED_AT,USD_AMOUNT}, sortOrder ∈ {asc,desc} (default desc) |
betId path param |
betId path param |
| Response shape | PaginatedDto<BetPublicDto>: { data, total, page, take, totalPages } |
HouseGameBetDetailsResponseDto: { betId, currencyId, amount, payout, settledAt, game, user } |
SlotGameBetDetailsResponseDto: same shape, game is BasicSlotGameBet |
| Caching | none — every hit runs Prisma | Redis bet:house-game-info:{betId}, 30 s TTL (@Cacheable) |
Redis bet:slot-game-info:{betId}, 30 s TTL |
BetPublicDto (bet.dto.ts:114-170) intentionally excludes payload — seed/nonce/RNG params live only in the detail endpoint. @Expose({ name: 'gameIdentity' }) renames the Prisma column gameIdentity to game on the wire; the list also includes a UserShortPublicDto snapshot (username, avatar, vipLevel — no email or balance).
Detail rejects with BET_IS_NOT_FOUND when bet.gameIdentity.type doesn't match the endpoint (HOUSE_GAME vs SLOTS) and filters on status=SETTLED inside the findUnique. For house games the stored short keys ({rv,thr,ab,w} dice, {rm,um} limbo, etc.) are inflated back to full names via deserializeHouseGameParams(slug, payload.params). For slots multiplier is recomputed on the fly as payout/amount (bet-crud.service.ts:114) rather than read from Bet.multiplier.
2. Sequence diagram¶
sequenceDiagram
participant U as Browser (authenticated)
participant MW as Express + Jwt(list)/no-guard(detail)
participant C as BetController
participant S as BetCrudService
participant R as Redis (cache)
participant PG as Postgres (Prisma)
alt List
U->>MW: GET /bets?page=1&take=20[&status=&sortBy=&sortOrder=]
MW->>C: findMany(query, req.user)
C->>S: findManyBets({...query, userId})
S->>PG: prisma.bet.count({where}) — Promise.all
S->>PG: prisma.bet.findMany({where, include, orderBy, take, skip})
PG-->>S: rows + total
S-->>C: PaginatedDto.as(BetPublicDto)
C-->>U: 200 { data:[BetPublicDto], total, page, take, totalPages }
else Detail (house game) — cache miss
U->>MW: GET /bets/house-games/info/:betId (no JWT check)
MW->>C: getBetInfo(betId)
C->>S: getHouseGameBetDetails({betId})
S->>R: GET bet:house-game-info:{betId}
R-->>S: nil
S->>PG: prisma.bet.findUnique({where:{id, status:SETTLED}, select: BetInfoDto.prismaSelect({withProvider:true})})
PG-->>S: bet + user + gameIdentity + provider
S->>S: deserializeHouseGameParams(slug, payload.params)
S-->>R: SETEX bet:house-game-info:{betId} TTL=30
S-->>U: 200 HouseGameBetDetailsResponseDto
else Detail — cache hit (within 30 s)
U->>MW: GET /bets/house-games/info/:betId
MW->>C: getBetInfo
C->>S: getHouseGameBetDetails
S->>R: GET bet:house-game-info:{betId}
R-->>S: cached payload
S-->>U: 200 (skips Prisma)
end
The detail trace ee3d303f… captured by the E2E is the cache-hit path (Redis get bet:house-game-info:bet-… followed immediately by getBetInfo returning — no Prisma op). The miss path adds a single prisma:client:operation Bet.findUnique and a Redis SETEX.
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 list and detail paths share almost no components, so they're drawn as two sub-diagrams; numbering increases monotonically across both.
3.1 List — GET /bets¶
flowchart TD
%% Datastores
pg[("Postgres<br/>Bet · User · GameIdentity")]
rd[("Redis (cache)<br/>auth-session · user details · BullMQ updateSessionQueue")]
%% NestJS process
subgraph api["ebit-api (NestJS)"]
ctrl["BetController.findMany<br/><i>GET /bets · @UseGuards(JwtGuard)</i>"]
jwt["JwtGuard<br/><i>cookie access_token → req.user</i>"]
svc["BetCrudService.findManyBets<br/><i>userId forced from JWT</i>"]
repo["BetRepository.findManyBets<br/><i>count + findMany via Promise.all</i>"]
pubdto["BetPublicDto<br/><i>excludes payload · gameIdentity → game</i>"]
end
%% (1)-(5) List request path
ctrl -- "(1) @UseGuards(JwtGuard)" --> jwt
jwt -- "(2) auth-session / user details probes" --> rd
ctrl -- "(3) findManyBets({...query, userId})" --> svc
svc -- "(4) pass-through" --> repo
repo -- "(5) Bet.count + Bet.findMany (Promise.all)" --> pg
repo -- "(6) serialize via PaginatedDto.as" --> pubdto
%% Style: datastores stand out
classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
class pg,rd db;
3.2 Detail — GET /bets/house-games/info/:betId and GET /bets/slots/info/:betId¶
flowchart TD
%% Datastores
pg[("Postgres<br/>Bet · User · GameIdentity · Provider")]
rd[("Redis (cache)<br/>bet:house-game-info:{betId} · bet:slot-game-info:{betId} TTL=30s")]
%% NestJS process
subgraph api["ebit-api (NestJS)"]
hctrl["BetController.getBetInfo<br/><i>GET /bets/house-games/info/:betId · guard commented (SF-008)</i>"]
sctrl["BetController.getSlotBetInfo<br/><i>GET /bets/slots/info/:betId · no guard (SF-008)</i>"]
cache["@Cacheable interceptor<br/><i>@type-cacheable/core · 30s TTL</i>"]
svc["BetCrudService<br/>.getHouseGameBetDetails · .getSlotGameBetDetails"]
repo["BetRepository.getBetInfoSettled<br/><i>findUnique status=SETTLED</i>"]
deser["deserializeHouseGameParams<br/><i>slug → inflate {rv,thr,ab,w} etc.</i>"]
infodto["HouseGameBetDetailsResponseDto<br/>/ SlotGameBetDetailsResponseDto"]
end
%% (7)-(8) Both detail controllers gated by @Cacheable
hctrl -- "(7) getHouseGameBetDetails(betId)" --> cache
sctrl -- "(8) getSlotGameBetDetails(betId)" --> cache
%% (9)-(10) Cache probe vs method body
cache -- "(9) GET bet:*-info:{betId}" --> rd
cache -- "(10) miss → invoke method body" --> svc
%% (11)-(12) Cache-miss path runs Prisma + inflates payload
svc -- "(11) getBetInfoSettled(betId)" --> repo
repo -- "(12) Bet.findUnique (+ User, GameIdentity, Provider)" --> pg
%% (13) House-game-only deserialize step
svc -- "(13) deserialize(slug, payload.params) — house only" --> deser
%% (14)-(15) Response shape + SETEX warm
svc -- "(14) shape response" --> infodto
cache -- "(15) SETEX bet:*-info:{betId} TTL=30 (SF-010)" --> rd
%% Style: datastores stand out
classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
class pg,rd db;
4. Per-step walkthrough¶
Section headers below mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram(s). The captured list trace ca1c99b5… is 37 spans / 18 ms; the captured detail trace ee3d303f… is the cache-hit path (one Redis get, zero Prisma ops). The 12-span middleware prefix on the list path is identical to sign-in (covered in dropbet-sign-in.md) and not repeated here.
4.1 Step (1) — BetController.findMany → JwtGuard¶
bet.controller.ts:19 declares @UseGuards(JwtGuard) on the list endpoint only (detail endpoints have it commented out — see §4.7 and SF-008). Handler body at bet.controller.ts:20-29 is one line: findManyBets({ ...query, userId: req.user.id }). The userId is always overwritten from the JWT, so a client cannot read another user's bets via this endpoint.
4.2 Step (2) — Auth/presence Redis probes (~3 ms)¶
JwtStrategy loads request.user from the access_token cookie. The trace shows three Redis get reads (auth-session:…, user:details:<userId>, zscore online_users) plus one evalsha bull:updateSessionQueue — not bet-specific, shared with every authenticated request (same prefix the sign-in flow uses).
4.3 Step (3) — BetController.findMany → BetCrudService.findManyBets¶
One-line pass-through that injects userId: req.user.id into the query. The controller never touches Prisma directly; SF-012's sportsbook exclusion sits one layer below in the repo, not here.
4.4 Step (4) — BetCrudService.findManyBets → BetRepository (pass-through)¶
The service-layer wrapper exists for future aggregation/detail growth without re-routing the controller. No DB calls; no transformation. It hands the query straight to BetRepository.findManyBets.
4.5 Step (5) — Bet.count + Bet.findMany (Promise.all)¶
bet.repository.ts:273-318. The where clause and order:
const where = { userId, gameIdentity: { type: { not: GameType.SPORTSBOOK } }, ...(query.status ? { status: query.status } : {}) };
const orderBy = { [CREATED_AT]: { createdAt: sortOrder ?? 'desc' }, [USD_AMOUNT]: { usdAmount: sortOrder ?? 'desc' } }[sortBy ?? CREATED_AT];
Sportsbook bets are excluded at the SQL layer — personal history is casino + slots only; sportsbook reads live elsewhere (SF-012). The trace shows Bet.count (5.2 ms) and Bet.findMany (7.4 ms) running in parallel under a single Promise.all (bet.repository.ts:301-310) — contrast with the admin variant which runs them sequentially (admin-bets.md SF-025). Bet.findMany uses include: BetDto.prismaInclude({withGame, withProvider}) (no withUser — userId is fixed). Backed by bets_user_id_created_at_index (userId, createdAt) BTree scanned backwards (SF-011).
4.6 Step (6) — PaginatedDto.as(BetPublicDto) shapes the response¶
new PaginatedDto(rows, total, query, BetDto).as(BetPublicDto) swaps the serializer so payload is dropped and gameIdentity is renamed to game. The list never exposes seed material — that's reserved for detail endpoints (and leaks via SF-008).
4.7 Steps (7)–(8) — Detail controllers enter @Cacheable¶
bet.controller.ts:32-41 (getBetInfo, house-game) and :43-50 (getSlotBetInfo, slot). @UseGuards(JwtGuard) is commented out on both (:31 and :43), and @Req() req is commented out at line 35-39 — see SF-008. Both handlers are one-liners that delegate to BetCrudService.getHouseGameBetDetails({betId}) / .getSlotGameBetDetails({betId}). Those service methods are decorated with @Cacheable({ cacheKey: args => bet:<house-game|slot>-info:${args[0].betId}, ttlSeconds: 30 }), so @type-cacheable/core interposes between the controller call and the method body.
4.8 Step (9) — GET bet:{house-game|slot}-info:{betId} cache probe¶
The interceptor issues a Redis get against the cache key. The captured detail trace ee3d303f… is the cache-hit path: this get returns the cached JSON and the method body never runs. The list endpoint has no equivalent cache (SF-011 mitigation).
4.9 Step (10) — Cache miss → invoke method body¶
On miss the interceptor calls through to getHouseGameBetDetails / getSlotGameBetDetails. Cache lookups for two adjacent requests on the same betId within 30 s collapse to a single Prisma call; cross-user lookups also collapse — see SF-010 (cache key omits userId).
4.10 Step (11) — BetCrudService → BetRepository.getBetInfoSettled¶
bet-crud.service.ts:80-83 (house) and :114 (slot) both call betRepository.getBetInfoSettled(betId). The repo method takes only the betId; the optional BetInfoQuery.userId from the DTO is never threaded through — see SF-009.
4.11 Step (12) — Bet.findUnique joins User + GameIdentity (+ Provider)¶
prisma.bet.findUnique({ where: {id, status: SETTLED}, select: BetInfoDto.prismaSelect({withProvider:true}) }). Joins Bet → User (public projection — no email/balance) and Bet → GameIdentity → Provider via select projections. Image fields collapse to customImages?.imageUrl ?? images?.imageUrl ?? null on the wire for both game and provider. A mismatch between bet.gameIdentity.type and the endpoint (HOUSE_GAME vs SLOTS) raises BET_IS_NOT_FOUND.
4.12 Step (13) — deserializeHouseGameParams (house games only)¶
bet/utils/bet.info.utils.ts. House-game-only branch: switches on gameIdentity.slug to call one of deserialize{Roulette,Plinko,Limbo,Keno,Mines,Dice,MonkeyRun,Blackjack}Params, inflating the stored short keys ({rv,thr,ab,w} dice, {rm,um} limbo, etc.) back to full names. The envelope (HouseGameBetPayload) carries multiplier, gameSeedNonce, gameHashedServerSeed for every slug — spread out of bet.payload into game (...bet.payload, params: deserialize(...)). Slots skip this step entirely; instead multiplier is recomputed on the fly as bet.payout.div(bet.amount).toFixed(2) (bet-crud.service.ts:114).
4.13 Step (14) — Shape HouseGameBetDetailsResponseDto / SlotGameBetDetailsResponseDto¶
Both DTOs share {betId, currencyId, amount, payout, settledAt, game, user}. House games' game carries the full RNG envelope + deserialized params; slots' game is BasicSlotGameBet (name, slug, type, image, provider, multiplier) with no RNG envelope (per-spin material isn't stored).
4.14 Step (15) — SETEX bet:{house-game|slot}-info:{betId} (cache warm)¶
The @Cacheable interceptor writes the shaped response to Redis with TTL 30 s. From this point any caller — guard or no guard — replays the cached payload until expiry (SF-010).
5. Data model¶
List and detail share one table — Bet — but project different columns.
| Object | R/W | Fields touched | Notes |
|---|---|---|---|
Bet (api.prisma:635-675) |
R (list) | id, createdAt, updatedAt, currencyId, amount, payout, usdAmount, usdPayout, status, multiplier + userId (where) + gameId (join) |
Driven by bets_user_id_created_at_index (userId, createdAt) BTree — scans it backwards for ORDER BY createdAt DESC. |
Bet |
R (detail) | full payload JSON + id, settledAt, currencyId, amount, payout |
findUnique on PK (bet_pkey); status=SETTLED is Prisma-side. |
User |
R | UserShortPublicDto.prismaSelect() — id, username, avatar, vipLevel, isStreamer flags |
Public projection, no email/balance. |
GameIdentity |
R | slug, name, type, images, customImages (+ provider.{name,images,customImages} on detail) |
Detail uses withProvider:true; list does not. |
Redis bet:house-game-info:{betId} · bet:slot-game-info:{betId} |
R/W | full detail DTO JSON | TTL 30 s; betId-only key — shared across users (SF-010). |
Index inventory on Bet: @@unique([roundId, userId]) (write-side double-settle), @@index([settledAt]), @@index([multiplier]) (both for public live feeds HighRollers/BigWins), @@index([createdAt]) (global latest feed), @@index([userId, createdAt]) — the last one is what makes personal history fast.
6. Failure modes¶
- SF-008 — Detail endpoints leak seed material to anonymous callers.
bet.controller.ts:31and:43mountgetBetInfo/getSlotBetInfowith noJwtGuard(line 31 explicitly comments it out). Any caller who can guess or enumerate abetIdreads the fullpayload—gameSeedNonce,gameHashedServerSeed,multiplier, deserialized RNG params — for any user. The E2E asserts the broken behavior so a future re-enable will fail loudly. Fix: re-enable the guard and re-add thereq.user.idargument commented at line 35-39 so ownership also gets enforced. - SF-009 —
BetInfoQuery.userIdis dead. The DTO has an optionaluserIdfilter that neithergetHouseGameBetDetailsnorgetSlotGameBetDetailsreads; the repository'sgetBetInfoSettleddoes not take a userId at all. Even if SF-008 is fixed and the controller passesreq.user.id, the service ignores it. Needs a siblinggetBetInfoSettledForUser(id, userId)to gate ownership. - SF-010 — Cache key is
betId-only, not(betId, userId). Once SF-008 is fixed, a 30 s cache miss serves the payload to any subsequent caller without re-checking the JWT subject — sensitive material still flows if the user is banned mid-cache-window. Fix: includeuserIdin the key or invalidate on ban. - SF-011 —
bets_user_id_created_at_indexis the only thing bounding list latency. TheWHERE userId AND status AND type != SPORTSBOOK ORDER BY createdAt DESChas no covering index forstatus; Postgres scans the BTree backwards on(userId, createdAt)and filters status in memory. Power users with 100 k+ bets + a non-default status filter degrade. Mitigation: defaultSETTLEDdominates and the UI rarely setsstatus. - SF-012 — Sportsbook bets silently invisible. The
gameIdentity.type != SPORTSBOOKfilter atbet.repository.ts:280-283is hard-coded; no sportsbook history endpoint exists onBetController. A "All my bets" UI built on this endpoint sees only casino with no hint.
7. Unresolved¶
- Pagination is offset-based, not keyset.
getSkip = (page-1) * take—OFFSET 10000 LIMIT 20is O(offset). Keyset on(createdAt, id)scales better; left for when a power user complains. - No rt-driven invalidation. The list has no Redis cache, so new bets appear on next poll. Live push goes via
LiveBetsNotificationService→MyBets/LatestBets/… from theBET_SETTLED_QUEUEworker (separate trace). The FE is expected to prepend incomingMyBetsclient-side; if it forgets, the user sees stale history until refresh. - Admin variant.
apps/bo/src/bet/bet-http.controller.tsexposesPOST /admin/bets(findManyBetsAdmin) — distinct route,BetDto(leakspayload), own guards, extra sort keys. Task #41. updatedAtis exposed but only moves on rewrite (rollback, multiplier recalculation). Consumers using it as "last activity" will be misled —settledAtis the right field, null when non-settled.- No metrics span on
@Cacheableoutcome. Abet.detail.cache.outcome=hit|missattribute ongetBetInfowould let us alert on cache storms. Wire alongside task #25.