Skip to content

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.ts Generated: 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.tsxqueries/challenges/index.ts:24,68:
  • GET /challenge — no JWT, paginated (FindManyChallengesPublicQuery). Returns ChallengePublicDto[] with id, 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 writes winnerId + issues the reward transaction.
  • Promos — read works, claim is broken. FE at rewards/components/Promocode/index.tsx:45queries/promo/index.ts:90-101:
  • GET /promo/publicOptionalAuthGuard, paginated.
  • GET /promo/public/:code — code lookup.
  • GET /promo/history — JWT, user's PromoBonusType.INSTANT claims.
  • GET /promo/bonuses — JWT, user's PromoBonusType.DEPOSIT claims.
  • POST /promo/public/:code { promoCodeId? } — UNREGISTERED (§6.1). claimPromoCode at promo.controller.ts:100 has @UseGuards + @Throttle + @ApiOperation but no HTTP verb decorator. Nest never registers the handler; live API returns 404 for every FE claim.
  • GET /challenge uses @Evo.ValidateDto(false) since FindManyChallengesPublicQuery is 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) inside PromoService.processOnCancelled then 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.tsxqueries/challenges/index.ts:24,68 issues three GETs:

  • GET /challenge — public, paginated, FindManyChallengesPublicQuery wrapped 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) — ChallengeControllerChallengeCrudService (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:45queries/promo/index.ts:90-101. Four reads:

  • GET /promo/publicOptionalAuthGuard, paginated public listing.
  • GET /promo/public/:code — code lookup.
  • GET /promo/history — JWT, user's PromoBonusType.INSTANT claims.
  • GET /promo/bonuses — JWT, user's PromoBonusType.DEPOSIT claims.

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) — PromoControllerPromoService 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) — PromoServicePromoValidatorService (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) — PromoServicePromoEffectService.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 → acctAccountingService.createOrFindTransaction({direction: DEPOSIT, tag: PROMO, …}).
  • Step (12) — acct → pgTransaction row 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 → rdpromoTaskService.scheduleExpiredTask with delay = progress.expiresAt - Date.now() enqueues a delayed expired-task on bull:promo-expired:*.
  • Step (16) — rd → prExpired — BullMQ pulls the delayed job at expiry.
  • Step (17) — prExpired → prSvcExpiredPromoWorker invokes PromoService.processOnCancelled.
  • Step (18)prSvc → prSvc (self-loop, dashed): promo.service.ts:36 calls expiresAt.getMilliseconds() (returns 0–999 ms-of-second) where .getTime() (epoch ms) was intended. The downstream if (expiresAt.getMilliseconds() < Date.now()) branch is therefore effectively always false, so processOnCancelled never 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 no user_balance is 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 → acctAdminChallengeController.award builds a transaction with tag=CHALLENGE_REWARD for the winning bet's user.
  • Step (22) — chAdmin → pgUPDATE 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

  1. POST /promo/public/:code is unregistered — every FE claim attempt 404s. PromoController.claimPromoCode at promo.controller.ts:100 has guards + @Throttle + @ApiOperation but 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 while GET /promo/public/:code works — 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.
  2. 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 on user_balance silently when an admin calls /admin/challenge/:id/award.
  3. FastTrackBonusService is disabled. PromoEffectService calls fastTrackBonusService.publish(...) to notify an external bonus tracker, but the module at fast-track.rmq.module.ts:8 is stubbed with disabled = true (CLAUDE.md §Async queues). All bonus events silently dropped until unblocked — same orphan class as ebit-bj.
  4. processExpiredUserPromoCodes has a .getMilliseconds() bug. promo.service.ts:36 reads expiresAt.getMilliseconds() — that returns 0-999 ms-of-second, not epoch ms. Intended was .getTime(). Effectively always false, so processOnCancelled never fires via this path. Low blast radius: the promo-expired BullMQ delayed job is the primary cleanup path.
  5. No captcha on the (dead) claim route. @Throttle({ profile: { limit: 3, ttl: 10000 } }) is the only abuse guard on the unregistered handler. If @Post is restored, there is no CAPTCHA for promos tied to real value.

7. Unresolved

  • Happy-path claim trace (findClaimable → validator → processOnClaim → scheduleExpiredTask) is untraceable until @Post is restored.
  • Admin award (/admin/challenge/:id/award) belongs to the admin-fe flow (task #40/#41).
  • fastTrackBonusService disposition 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.