Flow: dropbet challenges + promos (view/claim)¶
Trace IDs:
87632a921456d9b9a0706e66efc880b7(GET /challenge · 37 spans · 23 ms) ·8f927b9cdff66faed538e90a3d99986e(GET /promo/public · 35 spans · 17.8 ms) Jaeger: http://localhost:16686/trace/87632a921456d9b9a0706e66efc880b7 · http://localhost:16686/trace/8f927b9cdff66faed538e90a3d99986e · E2E:tests-e2e/tests/dropbet-challenges.spec.tsGenerated: 2026-04-16 · Services touched:ebit-api(only)
1. User-visible contract¶
Two surfaces share this doc because both live in apps/api, share AccountingService, and use BullMQ-only plumbing. Neither writes to the bet table.
- Challenges — view-only for end users. FE at
ebit-fe/src/app/[locale]/challenges/page.tsx→queries/challenges/index.ts:24,68: GET /challenge— no JWT, paginated (FindManyChallengesPublicQuery). ReturnsChallengePublicDto[]withid, targetGameId, minBetAmountUsd, minBetMultiplier, rewardAmount, rewardCurrency, winnerId, winnerSelectedAt, isHidden.GET /challenge/my— JWT, user's participation view (same DTO, filtered).GET /challenge/:id— public single-row.- No user-facing claim. Winner selection runs admin-side via
POST /admin/challenge/:id/award { betId }(admin-challenge.controller.ts), which writeswinnerId+ issues the reward transaction. - Promos — read works, claim is broken. FE at
rewards/components/Promocode/index.tsx:45→queries/promo/index.ts:90-101: GET /promo/public—OptionalAuthGuard, paginated.GET /promo/public/:code— code lookup.GET /promo/history— JWT, user'sPromoBonusType.INSTANTclaims.GET /promo/bonuses— JWT, user'sPromoBonusType.DEPOSITclaims.POST /promo/public/:code { promoCodeId? }— UNREGISTERED (§6.1).claimPromoCodeatpromo.controller.ts:100has@UseGuards+@Throttle+@ApiOperationbut no HTTP verb decorator. Nest never registers the handler; live API returns 404 for every FE claim.GET /challengeuses@Evo.ValidateDto(false)sinceFindManyChallengesPublicQueryis optional-everything. Read-side endpoints inherit default throttler only.
2. Sequence diagram¶
sequenceDiagram
participant U as Browser (dropbet FE)
participant API as ebit-api
participant PG as Postgres
participant R as Redis (cache)
Note over U,R: Challenges — browse only
U->>API: GET /challenge?page=1&take=20
API->>PG: findManyChallengesPublic (count + rows)
API-->>U: 200 { data, total, page, take }
U->>API: GET /challenge/my (JWT)
API->>PG: findMyChallenges (filter by user's eligible bets)
API-->>U: 200 { data, total }
Note over U,R: Promos — read works, claim 404s
U->>API: GET /promo/public (OptionalAuth)
API->>R: throttler keys (get · zscore · evalsha)
API->>PG: findManyPromoCodesPublic (affiliate filter)
API-->>U: 200 { data }
U->>API: GET /promo/history + /promo/bonuses (JWT)
API->>PG: findManyMyPromoCodesPublic (type=INSTANT|DEPOSIT)
API-->>U: 200 { data }
U->>API: POST /promo/public/:code { promoCodeId }
API-->>U: 404 (route never registered — §6.1)
sequenceDiagram
participant U as Browser
participant API as ebit-api
participant BQ as Redis (BullMQ)
participant PG as Postgres
Note over U,PG: HYPOTHETICAL happy-path claim (blocked by §6.1)
U->>API: POST /promo/public/:code {promoCodeId}
API->>PG: findClaimablePromoCode + validator checks
API->>API: PromoEffectService.processOnClaim
API->>PG: INSERT UserPromoCode · UserPromoCodeProgress
API->>PG: INSERT transaction (tag=PROMO) · UPDATE user_balance
API->>BQ: enqueue promo-expired `expired-task` delayed to progress.expiresAt
Note over BQ,PG: Admin awards a challenge (unrelated trace)
BQ->>API: challenges cron `scan` (*/15 min)
API->>PG: select eligible bets per challenge
loop per admin award action
U->>API: (admin) POST /admin/challenge/:id/award
API->>PG: UPDATE challenge SET winnerId · INSERT transaction (tag=CHALLENGE_REWARD)
end
3. Component diagram¶
Two diagrams — splitting by execution context. §3.1 covers all synchronous HTTP request paths (player-initiated reads + the broken claim attempt + the DI-only hypothetical claim chain). §3.2 covers background/scheduled work (BullMQ workers + admin writes) — these don't share callers with §3.1 and live in different traces.
Dashed red edges mark broken/stub paths: (8) missing @Post → 404 (FM-Challenges-1); (14) FastTrackBonusService stub → no-op (AF-6); (18) .getMilliseconds() bug → always-false branch (FM-Challenges-4). The ft node is styled red because the whole service is a stub.
3.1 Player-driven HTTP paths (steps 1–14)¶
flowchart LR
pg[("Postgres<br/>challenge · promo_code · user_promo_code<br/>· user_balance · transaction")]
rd[("Redis (cache)<br/>throttler keys")]
subgraph fe["Browser (ebit-fe dropbet)"]
direction TB
chClient["queries/challenges"]
prClient["queries/promo<br/><i>BonusDepositClaimModal</i>"]
end
subgraph apiCh["ebit-api — challenge module"]
chCtl["ChallengeController<br/><i>GET /challenge · /my · /:id</i>"]
chSvc["ChallengeCrudService<br/><i>findManyPublic · findMy</i>"]
end
subgraph apiPr["ebit-api — promo module"]
direction TB
prCtl["PromoController<br/><i>GET /promo/public · /history · /bonuses<br/>POST /:code → 404 (FM-Challenges-1)</i>"]
prSvc["PromoService<br/><i>claimPromoCode (DI-only)</i>"]
prValidator["PromoValidatorService"]
prEffect["PromoEffectService<br/><i>INSTANT vs DEPOSIT</i>"]
acct["AccountingService"]
ft["FastTrackBonusService<br/><i>stub · disabled=true (AF-6)</i>"]:::broken
end
chClient -- "(1) GET /challenge" --> chCtl
chCtl -- "(2) findManyPublic · findMy" --> chSvc
chSvc -- "(3) SELECT count + rows" --> pg
prClient -- "(4) GET /promo/public · …" --> prCtl
prCtl -- "(5) throttler get + evalsha" --> rd
prCtl -- "(6) findManyPromoCodes" --> prSvc
prSvc -- "(7) promo_code SELECTs" --> pg
prClient -. "(8) 404 POST /promo/public/:code<br/>FM-Challenges-1" .-> prCtl
prSvc -- "(9) validateClaim (DI-only)" --> prValidator
prSvc -- "(10) processOnClaim" --> prEffect
prEffect -- "(11) INSTANT credit" --> acct
acct -- "(12) transaction + balance" --> pg
prEffect -- "(13) INSERT UserPromoCode" --> pg
prEffect -. "(14) publish bonus event<br/>STUB AF-6" .-> ft
classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
classDef broken fill:#5a1f1f,stroke:#ff8a8a,color:#fff,stroke-dasharray:4 2;
class pg,rd db;
linkStyle 7,13 stroke:#cc0000,stroke-width:2.5px;
3.2 Background workers + admin writes (steps 15–22)¶
flowchart LR
pg[("Postgres<br/>challenge · user_promo_code_progress<br/>· user_balance · transaction")]
rd[("Redis (cache)<br/>bull:challenges · bull:promo-expired")]
subgraph apiPr["ebit-api — promo module"]
prEffect["PromoEffectService"]
prSvc["PromoService<br/><i>processOnCancelled</i>"]
end
subgraph apiCh["ebit-api — challenge module (admin)"]
chAdmin["AdminChallengeController<br/><i>POST /admin/challenge/:id/award · /rollback</i>"]
acct["AccountingService<br/><i>tag=CHALLENGE_REWARD</i>"]
end
subgraph wk["BullMQ workers (same process, separate consumers)"]
direction TB
prExpired["ExpiredPromoWorker<br/><i>@Processor promo-expired</i>"]
chScan["ChallengeScanProcessor<br/><i>cron */15 enumerates eligible bets</i>"]
end
prEffect -- "(15) scheduleExpiredTask delay" --> rd
rd -- "(16) delayed task pulled" --> prExpired
prExpired -- "(17) processOnCancelled call" --> prSvc
prSvc -. "(18) .getMilliseconds() bug<br/>FM-Challenges-4 always false" .-> prSvc
rd -- "(19) scan job pulled */15" --> chScan
chScan -- "(20) enumerate eligible bets" --> pg
chAdmin -- "(21) award → transaction" --> acct
acct --> pg
chAdmin -- "(22) UPDATE challenge.winnerId" --> pg
classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
class pg,rd db;
linkStyle 3 stroke:#cc0000,stroke-width:2.5px;
Edge (18) is the only red-thick edge in §3.2 — it's the always-false
.getMilliseconds()branch (FM-Challenges-4). Step (17) lands cleanly; step (18) insidePromoService.processOnCancelledthen silently never fires the cleanup it was meant to.
4. Per-step walkthrough¶
Section headers below mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram. Captured traces 87632a92… (GET /challenge, 23 ms · 37 spans) and 8f927b9c… (GET /promo/public, 17.8 ms · 35 spans) cover steps (1)–(7). Broken paths (8)/(14)/(17)/(18) are unreachable / no-op and are described as such; the hypothetical claim path (9)–(13) is wired in DI but not exposed over HTTP. Worker traces (15)–(20) and admin writes (21)–(22) are separate flows.
4.1 Step (1) — GET /challenge family hits ChallengeController¶
Standard Nest middleware chain (11 spans). FE at ebit-fe/src/app/[locale]/challenges/page.tsx → queries/challenges/index.ts:24,68 issues three GETs:
GET /challenge— public, paginated,FindManyChallengesPublicQuerywrapped by@Evo.ValidateDto(false)(everything optional).GET /challenge/my— JWT, user's participation view.GET /challenge/:id— public single row.
There is no user-facing claim verb on this controller; winner selection rides admin-only (see §4.13).
4.2 Step (2) — ChallengeController → ChallengeCrudService (19.5 ms wrapper)¶
ChallengeController.findManyChallenges (19.5 ms) hands off to ChallengeCrudService.findManyChallengesPublic. Same shape for findMy and findUnique. Service is a thin pass-through; the wrapper span dominates because both downstream Prisma ops are its children.
4.3 Step (3) — Postgres Challenge SELECTs (count + rows)¶
Two prisma:client:operation spans (5.2 ms + 10.2 ms) = one COUNT + one SELECT for the paginated listing. Four prisma:engine:query + two prisma:engine:connection underneath. No Redis ops except the initial throttler get (covered in §4.5 for promos — challenges path inherits the global throttler with the same shape).
4.4 Step (4) — GET /promo/public family hits PromoController¶
FE at ebit-fe/src/app/[locale]/rewards/components/Promocode/index.tsx:45 → queries/promo/index.ts:90-101. Four reads:
GET /promo/public—OptionalAuthGuard, paginated public listing.GET /promo/public/:code— code lookup.GET /promo/history— JWT, user'sPromoBonusType.INSTANTclaims.GET /promo/bonuses— JWT, user'sPromoBonusType.DEPOSITclaims.
4.5 Step (5) — Throttler probes against Redis (~4 ms)¶
Four ioredis spans on the GET /promo/public trace: get, get, zscore, evalsha. This is the Nest global throttler's Lua sliding-window counter (@nestjs/throttler storage = ioredis). Same pattern is present on every read endpoint in this flow; only called out once on the diagram because it's the same node-pair.
4.6 Step (6) — PromoController → PromoService read methods¶
PromoController.findManyPromoCodesPublic (13.0 ms) delegates to PromoService.findManyPromoCodesPublic / findMyPromoCodes. Repository uses a windowed LIMIT with a post-hoc total estimate (different shape from challenges' explicit COUNT + SELECT pair).
4.7 Step (7) — Postgres promo_code / user_promo_code SELECTs¶
One prisma:client:operation (2.82 ms) — the public list is a single JOIN. The JWT-gated /history and /bonuses variants add a where: { userId, type: INSTANT | DEPOSIT } filter against user_promo_code joined with user_promo_code_progress.
4.8 Step (8) — POST /promo/public/:code is unregistered (FM-Challenges-1, broken)¶
PromoController.claimPromoCode (promo.controller.ts:100-112) has @UseGuards(JwtGuard, UserHttpThrottlerGuard) + @Throttle({ profile: { limit: 3, ttl: 10000 } }) + @ApiOperation(...) but no HTTP verb decorator. Nest's route scanner ignores it; the handler is instantiated in the DI graph (so PromoService.claimPromoCode is fully wired) but never exposed over HTTP. Empirical probe in E2E: 404. The controller's @Get('public/:code') at line 79 registers only the GET side, which is why GET /promo/public/:code works while POST doesn't.
Diagram: dashed edge (8) labelled [BROKEN 404]. Steps (9)–(13) below describe what would run if the missing @Post('public/:code') were restored. See §6 #1 + weaknesses-register FM-Challenges-1.
4.9 Step (9) — PromoService → PromoValidatorService (DI-only, unreachable via HTTP)¶
PromoValidatorService runs the claim-condition gates (active flag, expiry, affiliate match, per-user limit, claim window). Fully wired in DI but only reachable from PromoService.claimPromoCode, which itself has no HTTP entry point until FM-Challenges-1 is fixed.
4.10 Step (10) — PromoService → PromoEffectService.processOnClaim¶
Branches on bonus_type: INSTANT → immediate transaction + user_balance update via AccountingService; DEPOSIT → seeds user_promo_code_progress with vault state, requires a subsequent qualifying deposit to activate.
4.11 Step (11)–(12) — INSTANT credit via AccountingService → Postgres¶
PromoEffectService for INSTANT builds an AccountingArgs with tag=PROMO and calls AccountingService.createOrFindTransaction. Same shape as the bet-place WITHDRAW/DEPOSIT pair (see dropbet-bet-place.md §4.4), but with tag=PROMO instead of BET and only the DEPOSIT leg.
- Step (11) —
prEffect → acct—AccountingService.createOrFindTransaction({direction: DEPOSIT, tag: PROMO, …}). - Step (12) —
acct → pg—Transactionrow insert +UserBalance.upsert(credit).
4.12 Step (13) — UserPromoCode + UserPromoCodeProgress INSERT¶
PromoEffectService writes the user_promo_code row (status=CLAIMED→ACTIVE for INSTANT, status=CLAIMED awaiting deposit for DEPOSIT) and a paired user_promo_code_progress row carrying base_amount, total_wagered, and expiresAt.
4.13 Step (14) — FastTrackBonusService.publish is a no-op stub (AF-6, broken)¶
PromoEffectService calls fastTrackBonusService.publish(...) to notify an external bonus tracker. The module at apps/api/src/fast-track/rabbitmq/fast-track.rmq.module.ts:8 returns a stubbed producer with disabled = true. RabbitMQ is up (vhost ft) but receives zero traffic from any of the 11 promo + bet call sites until the stub is removed.
Diagram: dashed edge (14) labelled [STUB AF-6], target node ft styled as broken. See weaknesses-register.md AF-6 and §6 #3 below.
4.14 Steps (15)–(18) — promo-expired worker (separate trace, with a bug)¶
Steps (15)–(17) are a separate trace rooted on BullMQJob promo-expired (same propagation gap as bet-place — see AF-2 in weaknesses-register.md).
- Step (15) —
prEffect → rd—promoTaskService.scheduleExpiredTaskwithdelay = progress.expiresAt - Date.now()enqueues a delayedexpired-taskonbull:promo-expired:*. - Step (16) —
rd → prExpired— BullMQ pulls the delayed job at expiry. - Step (17) —
prExpired → prSvc—ExpiredPromoWorkerinvokesPromoService.processOnCancelled. - Step (18) —
prSvc → prSvc(self-loop, dashed):promo.service.ts:36callsexpiresAt.getMilliseconds()(returns 0–999 ms-of-second) where.getTime()(epoch ms) was intended. The downstreamif (expiresAt.getMilliseconds() < Date.now())branch is therefore effectively always false, soprocessOnCancellednever fires via this path. Low blast radius because the delayed BullMQ job in step (16) is the primary cleanup mechanism — but this defensive scan is dead. See §6 #4 +FM-Challenges-4.
In practice this whole chain only sees traffic when an admin seeds a user_promo_code row directly or imports historical data, because step (15) is only fed by the (dead) claim path.
4.15 Steps (19)–(20) — challenges cron scan (separate trace)¶
apps/api/src/challenge/bullmq/processors/challenge-scan.processor.ts runs a single recurring scan job at */15 * * * *.
- Step (19) —
rd → chScan— BullMQ pulls the cron job. - Step (20) —
chScan → pg— enumerates active challenges, finds eligible bets per(target_game_id, min_bet_amount_usd, min_bet_multiplier), and stages them for admin winner-selection. No user action triggers this and nouser_balanceis touched.
4.16 Steps (21)–(22) — Admin award (POST /admin/challenge/:id/award)¶
The only path that mutates user_balance for a challenge. Admin-fe only; out of scope for the dropbet user contract but included to show the complete data flow (challenges have no user-side claim, FM-Challenges-2).
- Step (21) —
chAdmin → acct—AdminChallengeController.awardbuilds atransactionwithtag=CHALLENGE_REWARDfor the winning bet's user. - Step (22) —
chAdmin → pg—UPDATE challenge SET winnerId=…, winner_selected_at=NOW()in the same Prisma transaction.
Full admin flow belongs to task #40/#41 (admin-fe challenges doc).
5. Data model¶
| Store | Key / table | R/W | Fields | Source |
|---|---|---|---|---|
| Postgres | challenge |
R (public/admin), W (admin only) | id, target_game_id, min_bet_amount_usd, min_bet_multiplier, reward_amount, reward_currency, winner_id, winner_confirmed_by_id, winner_selected_at, is_hidden, created_by_id, created_at, updated_at |
libs/_prisma/src/schema/api.prisma |
| Postgres | promo_code |
R | id, code, is_active, is_hidden, bonus_type, amount, currency_id, expires_at, affiliate_code_id, claim_conditions (JSONB), ... |
libs/_prisma/src/schema/api.prisma |
| Postgres | user_promo_code + user_promo_code_progress |
R | status (CLAIMED→ACTIVE→COMPLETED or CANCELLED/EXPIRED), claimed_at, activated_at, expires_at, progress.base_amount, progress.total_wagered |
libs/_prisma/src/schema/api.prisma |
| Postgres | transaction |
W (award, INSTANT claim — hypothetical) | tag=CHALLENGE_REWARD (admin award) or tag=PROMO (promo credit); amount, before/after_balance, user_id, game_id, type |
libs/_prisma/src/schema/api.prisma |
| Postgres | user_balance |
W (admin challenge award · INSTANT promo claim) | amount delta |
libs/_prisma/src/schema/api.prisma |
| Redis (cache) | bull:challenges:* |
R+W | scan job payload (cron */15 * * * *), 3 attempts, exp backoff 1 s |
challenge-bullmq.constants.ts |
| Redis (cache) | bull:promo-expired:* |
R+W | delayed expired-task { userPromoCodeId, userId }, fires at progress.expiresAt |
expired-promo.worker.ts |
6. Failure modes¶
POST /promo/public/:codeis unregistered — every FE claim attempt 404s.PromoController.claimPromoCodeatpromo.controller.ts:100has guards +@Throttle+@ApiOperationbut no HTTP-verb decorator. Nest skips it during route discovery;PromoService.claimPromoCode+ validators + effects are DI-wired and callable internally, but no HTTP entry point exists. Probed 2026-04-16:POST /promo/public/...→ 404 whileGET /promo/public/:codeworks — omission is verb-specific. E2E pins this 404; fix is one line (@Post('public/:code')at line 99) and flips both the test and this doc.- Challenges have no user-triggered state change. Browse + view-my-participation is the entire user contract. Winner selection, award, rollback are admin-only at
admin-challenge.controller.ts. An end user has no claim route; rewards land onuser_balancesilently when an admin calls/admin/challenge/:id/award. - FastTrackBonusService is disabled.
PromoEffectServicecallsfastTrackBonusService.publish(...)to notify an external bonus tracker, but the module atfast-track.rmq.module.ts:8is stubbed withdisabled = true(CLAUDE.md §Async queues). All bonus events silently dropped until unblocked — same orphan class as ebit-bj. processExpiredUserPromoCodeshas a.getMilliseconds()bug.promo.service.ts:36readsexpiresAt.getMilliseconds()— that returns 0-999 ms-of-second, not epoch ms. Intended was.getTime(). Effectively always false, soprocessOnCancellednever fires via this path. Low blast radius: thepromo-expiredBullMQ delayed job is the primary cleanup path.- No captcha on the (dead) claim route.
@Throttle({ profile: { limit: 3, ttl: 10000 } })is the only abuse guard on the unregistered handler. If@Postis restored, there is no CAPTCHA for promos tied to real value.
7. Unresolved¶
- Happy-path claim trace (findClaimable → validator → processOnClaim → scheduleExpiredTask) is untraceable until
@Postis restored. - Admin award (
/admin/challenge/:id/award) belongs to the admin-fe flow (task #40/#41). fastTrackBonusServicedisposition same open question as #21 and ebit-bj.- Deposit-bonus activation (game whitelist + wagering) lives behind the dead claim route + payment webhook; not exercisable from dropbet here.