From 2431798f82d021a19b66ce81ce120419b87f9061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Wed, 4 Nov 2026 18:20:19 +0100 Subject: [PATCH 1/2] fix(gamification)!: every product list names its products, with a backfill An empty rank, rakeback, rank-challenge or streak product list no longer means every product. Like races, each list must name known products, at least one, none repeated; the admin config saves refuse anything else. Migration 0022 drops the empty defaults, gives every empty list all four products so no live config changes what it counts, and adds a non-empty CHECK on all four columns. BREAKING CHANGE: rank and streak eligibleProducts accept only WagerProduct values and must be non-empty, and the rakeback and rank-challenge lists must be non-empty when sent. A save with [] or an unknown product returns 400. --- docs/modules/gamification.md | 15 +- .../eligible-products-backfill.int.test.ts | 187 ++ .../__tests__/rakeback.int.test.ts | 12 +- .../__tests__/rank-challenge.int.test.ts | 20 +- .../__tests__/rank-payout.int.test.ts | 2 +- .../rank-perk-products-backfill.int.test.ts | 46 +- .../gamification/__tests__/rank.int.test.ts | 4 +- .../__tests__/streak-config.test.ts | 10 + .../__tests__/streak-payout.int.test.ts | 2 +- .../src/promo/gamification/contract/index.ts | 27 +- .../0022_rank_streak_products_required.sql | 16 + .../migrations/meta/0022_snapshot.json | 2353 +++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + .../src/promo/gamification/schema/index.ts | 144 +- .../core/src/promo/gamification/seed/index.ts | 4 +- .../gamification/service/rakeback.service.ts | 2 +- .../service/rank-challenge.service.ts | 2 +- .../gamification/service/rank.service.ts | 3 +- .../gamification/service/streak.service.ts | 3 +- .../__tests__/promo-rank-config.e2e.test.ts | 47 +- .../__tests__/promo-streak-claim.e2e.test.ts | 2 +- .../__tests__/promo-streak-config.e2e.test.ts | 72 + .../sportsbook-promotions.e2e.test.ts | 2 +- packages/testing/src/seed.ts | 3 +- 24 files changed, 2847 insertions(+), 138 deletions(-) create mode 100644 packages/core/src/promo/gamification/__tests__/eligible-products-backfill.int.test.ts create mode 100644 packages/core/src/promo/gamification/drizzle/migrations/0022_rank_streak_products_required.sql create mode 100644 packages/core/src/promo/gamification/drizzle/migrations/meta/0022_snapshot.json create mode 100644 packages/testing/src/__tests__/promo-streak-config.e2e.test.ts diff --git a/docs/modules/gamification.md b/docs/modules/gamification.md index 1ca75395d..555befdb0 100644 --- a/docs/modules/gamification.md +++ b/docs/modules/gamification.md @@ -40,14 +40,17 @@ because a rate provider is slow would be a worse failure than undercounting thei What counts toward a rank is the raw stake, not what a bonus engine weighted it at. Weighting belongs to a bonus a player happens to hold; a player's standing must not depend on which bonus they are carrying. Which products count - casino, live casino, sportsbook - is the operator's -setting, and an empty setting counts every bet. +setting. Rakeback and the rank challenge each have a product list of their own, and a new ladder's lists -leave sportsbook out until the operator adds it. A ladder configured before these lists existed -was upgraded to empty ones, so it keeps paying on every product it did. Rakeback pays only on a -stake both the rank's list and the rakeback list admit. The rank challenge does not need the -ladder's settings row: without one it counts the default list. An empty list counts every -product here too. Both lists are set on `PUT /backoffice/promo/ranks/config`, so changing one +leave sportsbook out until the operator adds it. Rakeback pays only on a stake both the rank's +list and the rakeback list admit. The rank challenge does not need the ladder's settings row: +without one it counts the default list. Every product list - the rank's, rakeback's, the rank +challenge's and the streak's - names its products, and a save with an empty list, a repeated +product or an unknown one is refused. An empty list used to count every product, so a list saved +empty before that rule was given all four on upgrade and keeps counting what it did. A list +naming only unknown products was kept and counts nothing until the operator saves a valid one. +Both lists are set on `PUT /backoffice/promo/ranks/config`, so changing one writes the ladder's settings row with it; a save that leaves a list or `periodicMinimumWager` out keeps the stored one, and only an explicit `null` clears the minimum. diff --git a/packages/core/src/promo/gamification/__tests__/eligible-products-backfill.int.test.ts b/packages/core/src/promo/gamification/__tests__/eligible-products-backfill.int.test.ts new file mode 100644 index 000000000..e61ec0894 --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/eligible-products-backfill.int.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { randomUUID } from 'node:crypto'; +import { sql } from 'drizzle-orm'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import { mock } from '../../../testing/mock.js'; +import type { WagerProduct, WalletCommands } from '@openora/core/contracts'; +import { migrate } from '../migrate.js'; +import { + promoPlayerRank, + promoRankConfig, + promoRankTier, + promoStreakConfig, +} from '../schema/index.js'; +import { DEFAULT_PAYOUT_ANCHORS } from '../contract/index.js'; +import { RakebackService } from '../service/rakeback.service.js'; + +const MIGRATIONS = new URL('../drizzle/migrations/', import.meta.url); +const EVERY_PRODUCT = ['casino', 'live-casino', 'sportsbook', 'pvp'] satisfies WagerProduct[]; + +const BACKFILL_TAG = ( + JSON.parse(readFileSync(fileURLToPath(new URL('meta/_journal.json', MIGRATIONS)), 'utf8')) as { + entries: { tag: string }[]; + } +).entries + .map((e) => e.tag) + .find((tag) => tag.endsWith('_rank_streak_products_required')); + +const BACKFILL_SQL = readFileSync( + fileURLToPath(new URL(`${BACKFILL_TAG}.sql`, MIGRATIONS)), + 'utf8', +).split('--> statement-breakpoint'); + +let db: TestDb; +const credit = vi.fn(); +let rakeback: RakebackService; + +beforeAll(async () => { + db = await createTestDb([migrate]); + rakeback = new RakebackService(() => mock({ credit }), { warn: vi.fn() }); +}); + +afterAll(() => db.drop()); + +beforeEach(async () => { + vi.clearAllMocks(); + credit.mockResolvedValue({ ok: true, moved: true, transactionId: randomUUID(), newBalance: '0' }); + await db.drizzle.db.delete(promoPlayerRank); + await db.drizzle.db.delete(promoRankTier); + await db.drizzle.db.delete(promoRankConfig); + await db.drizzle.db.delete(promoStreakConfig); +}); + +async function asBeforeTheUpgrade() { + await db.drizzle.db.execute(sql`ALTER TABLE "promo_rank_config" + DROP CONSTRAINT "promo_rank_config_eligible_products_set", + DROP CONSTRAINT "promo_rank_config_rakeback_eligible_products_set", + DROP CONSTRAINT "promo_rank_config_rank_challenge_eligible_products_set", + ALTER COLUMN "eligible_products" SET DEFAULT '{}'`); + await db.drizzle.db.execute(sql`ALTER TABLE "promo_streak_config" + DROP CONSTRAINT "promo_streak_config_eligible_products_set", + ALTER COLUMN "eligible_products" SET DEFAULT '{}'`); +} + +async function upgrade() { + for (const statement of BACKFILL_SQL) { + await db.drizzle.db.execute(sql.raw(statement)); + } +} + +const saveRankConfig = ( + lists: Pick< + typeof promoRankConfig.$inferInsert, + 'eligibleProducts' | 'rakebackEligibleProducts' | 'rankChallengeEligibleProducts' + >, +) => + db.drizzle.db.insert(promoRankConfig).values({ ...lists, payoutAnchors: DEFAULT_PAYOUT_ANCHORS }); + +const saveStreakConfig = (eligibleProducts: string[]) => + db.drizzle.db + .insert(promoStreakConfig) + .values({ currency: 'USDT', dailyMinWager: '10', eligibleProducts }); + +async function playerOnTier() { + const [tier] = await db.drizzle.db + .insert(promoRankTier) + .values({ + key: 'bronze', + name: 'Bronze', + position: 0, + currency: 'USDT', + wagerThreshold: '0', + rakebackPercent: '1', + }) + .returning({ id: promoRankTier.id }); + const userId = randomUUID(); + await db.drizzle.db + .insert(promoPlayerRank) + .values({ userId, currency: 'USDT', tierId: tier?.id }); + return userId; +} + +describe('product lists on upgrade', () => { + it('gives every empty list all products, so a sportsbook bet still earns rakeback', async () => { + await asBeforeTheUpgrade(); + await saveRankConfig({ + eligibleProducts: [], + rakebackEligibleProducts: [], + rankChallengeEligibleProducts: [], + }); + await saveStreakConfig([]); + const userId = await playerOnTier(); + + await upgrade(); + await db.drizzle.db.transaction((tx) => + rakeback.recordSettledWager(tx, { + userId, + currency: 'USDT', + realAmount: '100', + context: { provider: 'sportsbook', product: 'sportsbook' }, + round: { providerName: 'sportsbook:provider', externalRoundId: randomUUID() }, + qualifiedAt: new Date(), + }), + ); + + const [rank] = await db.drizzle.db.select().from(promoRankConfig); + const [streak] = await db.drizzle.db.select().from(promoStreakConfig); + expect(rank).toMatchObject({ + eligibleProducts: EVERY_PRODUCT, + rakebackEligibleProducts: EVERY_PRODUCT, + rankChallengeEligibleProducts: EVERY_PRODUCT, + }); + expect(streak?.eligibleProducts).toEqual(EVERY_PRODUCT); + expect(credit).toHaveBeenCalledTimes(1); + }); + + it('keeps a list the operator named, even one naming no product the platform has', async () => { + await asBeforeTheUpgrade(); + await saveRankConfig({ + eligibleProducts: ['slots'], + rakebackEligibleProducts: ['casino'], + rankChallengeEligibleProducts: ['pvp', 'sportsbook'], + }); + await saveStreakConfig(['live-casino']); + + await upgrade(); + + const [rank] = await db.drizzle.db.select().from(promoRankConfig); + const [streak] = await db.drizzle.db.select().from(promoStreakConfig); + expect(rank).toMatchObject({ + eligibleProducts: ['slots'], + rakebackEligibleProducts: ['casino'], + rankChallengeEligibleProducts: ['pvp', 'sportsbook'], + }); + expect(streak?.eligibleProducts).toEqual(['live-casino']); + }); + + it('refuses an empty list once upgraded', async () => { + const violation = (constraint: string) => ({ + cause: expect.objectContaining({ code: '23514', constraint }), + }); + + await expect( + saveRankConfig({ + eligibleProducts: [], + rakebackEligibleProducts: ['casino'], + rankChallengeEligibleProducts: ['casino'], + }), + ).rejects.toMatchObject(violation('promo_rank_config_eligible_products_set')); + await expect(saveStreakConfig([])).rejects.toMatchObject( + violation('promo_streak_config_eligible_products_set'), + ); + + await saveRankConfig({ + eligibleProducts: ['casino'], + rakebackEligibleProducts: ['casino'], + rankChallengeEligibleProducts: ['casino'], + }); + await expect( + db.drizzle.db.update(promoRankConfig).set({ rakebackEligibleProducts: [] }), + ).rejects.toMatchObject(violation('promo_rank_config_rakeback_eligible_products_set')); + await expect( + db.drizzle.db.update(promoRankConfig).set({ rankChallengeEligibleProducts: [] }), + ).rejects.toMatchObject(violation('promo_rank_config_rank_challenge_eligible_products_set')); + }); +}); diff --git a/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts b/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts index 70c9c8ea9..0996b0762 100644 --- a/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts @@ -37,7 +37,7 @@ const LADDER = { { key: 'gold', name: 'Gold', wagerThreshold: '50000', rakebackPercent: '5' }, ], config: { - eligibleProducts: [], + eligibleProducts: ['casino', 'live-casino', 'sportsbook', 'pvp'] satisfies WagerProduct[], rewards: {}, payoutAnchors: DEFAULT_PAYOUT_ANCHORS, payInPlayerCurrency: false, @@ -289,16 +289,6 @@ describe('which products earn rakeback', () => { expect(credit).not.toHaveBeenCalled(); }); - - it('counts every product the rank counts when the rakeback list is empty', async () => { - const userId = randomUUID(); - await givePlayerTier(userId, 'bronze'); - await db.drizzle.db.update(promoRankConfig).set({ rakebackEligibleProducts: [] }); - - await settledBet(rakeback, userId, 'bet-1'); - - expect(credit).toHaveBeenCalledTimes(1); - }); }); describe('rakeback on the house edge', () => { diff --git a/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts b/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts index e25979b74..a30ffb7a6 100644 --- a/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts @@ -217,9 +217,10 @@ describe('which products count toward the Rank Challenge', () => { it('counts a sportsbook stake once the operator adds it to the list', async () => { const { account } = await seedPlayerWithUser(db); - await db.drizzle.db - .insert(promoRankConfig) - .values({ rankChallengeEligibleProducts: ['casino', 'sportsbook'] }); + await db.drizzle.db.insert(promoRankConfig).values({ + eligibleProducts: ['casino'], + rankChallengeEligibleProducts: ['casino', 'sportsbook'], + }); await wagerOn(account.id, 'sportsbook'); @@ -229,21 +230,14 @@ describe('which products count toward the Rank Challenge', () => { it('leaves out a product the operator removed from the list', async () => { const { account } = await seedPlayerWithUser(db); - await db.drizzle.db.insert(promoRankConfig).values({ rankChallengeEligibleProducts: ['pvp'] }); + await db.drizzle.db + .insert(promoRankConfig) + .values({ eligibleProducts: ['casino'], rankChallengeEligibleProducts: ['pvp'] }); await wagerOn(account.id, 'casino'); expect(await wageredBy(account.id)).toBeUndefined(); }); - - it('counts every product when the list is empty', async () => { - const { account } = await seedPlayerWithUser(db); - await db.drizzle.db.insert(promoRankConfig).values({ rankChallengeEligibleProducts: [] }); - - await wagerOn(account.id, 'sportsbook'); - - expect(await wageredBy(account.id)).toBe('1.000000000000000000'); - }); }); describe('settling a claim', () => { diff --git a/packages/core/src/promo/gamification/__tests__/rank-payout.int.test.ts b/packages/core/src/promo/gamification/__tests__/rank-payout.int.test.ts index 47e01212e..1822e5d02 100644 --- a/packages/core/src/promo/gamification/__tests__/rank-payout.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rank-payout.int.test.ts @@ -132,7 +132,7 @@ beforeEach(async () => { }, ], config: { - eligibleProducts: [], + eligibleProducts: ['casino', 'live-casino', 'sportsbook', 'pvp'], rewards: { levelUp: LEVEL_UP_TERMS, daily: DAILY_TERMS }, payoutAnchors: DEFAULT_PAYOUT_ANCHORS, payInPlayerCurrency: false, diff --git a/packages/core/src/promo/gamification/__tests__/rank-perk-products-backfill.int.test.ts b/packages/core/src/promo/gamification/__tests__/rank-perk-products-backfill.int.test.ts index 2c6996c65..4ac8fc391 100644 --- a/packages/core/src/promo/gamification/__tests__/rank-perk-products-backfill.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rank-perk-products-backfill.int.test.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; import { createTestDb, type TestDb } from '@openora/core/testing'; import { mock } from '../../../testing/mock.js'; -import type { WalletCommands } from '@openora/core/contracts'; +import type { WagerProduct, WalletCommands } from '@openora/core/contracts'; import { migrate } from '../migrate.js'; import { promoPlayerRank, promoRankConfig, promoRankTier } from '../schema/index.js'; import { DEFAULT_PAYOUT_ANCHORS, DEFAULT_RANK_PERK_PRODUCTS } from '../contract/index.js'; @@ -15,6 +15,7 @@ import { RakebackService } from '../service/rakeback.service.js'; const MIGRATIONS = new URL('../drizzle/migrations/', import.meta.url); const ADDED_IN = '0018_aberrant_killer_shrike'; const BACKFILLED_IN = '0019_rank_perk_products_backfill'; +const EVERY_PRODUCT = ['casino', 'live-casino', 'sportsbook', 'pvp'] satisfies WagerProduct[]; const JOURNAL_TAGS = ( JSON.parse(readFileSync(fileURLToPath(new URL('meta/_journal.json', MIGRATIONS)), 'utf8')) as { @@ -27,16 +28,16 @@ const sqlOf = (tag: string) => '--> statement-breakpoint', ); -const UPGRADE_SQL = JOURNAL_TAGS.slice( - JOURNAL_TAGS.indexOf(ADDED_IN), - JOURNAL_TAGS.indexOf(BACKFILLED_IN) + 1, -).flatMap(sqlOf); +const BACKFILL_SQL = [ + BACKFILLED_IN, + JOURNAL_TAGS.find((tag) => tag.endsWith('_rank_streak_products_required')) ?? '', +].flatMap(sqlOf); const LADDER = { currency: 'USDT', tiers: [{ key: 'bronze', name: 'Bronze', wagerThreshold: '0', rakebackPercent: '1' }], config: { - eligibleProducts: [], + eligibleProducts: EVERY_PRODUCT, rewards: {}, payoutAnchors: DEFAULT_PAYOUT_ANCHORS, payInPlayerCurrency: false, @@ -63,10 +64,19 @@ beforeEach(async () => { await db.drizzle.db.delete(promoRankConfig); }); -async function upgradeFromBeforeProductLists() { +async function addProductLists() { await db.drizzle.db.execute(sql`ALTER TABLE "promo_rank_config" - DROP COLUMN "rakeback_eligible_products", DROP COLUMN "rank_challenge_eligible_products"`); - for (const statement of UPGRADE_SQL) { + DROP COLUMN "rakeback_eligible_products", DROP COLUMN "rank_challenge_eligible_products", + DROP CONSTRAINT "promo_rank_config_eligible_products_set"`); + await db.drizzle.db.execute(sql`ALTER TABLE "promo_streak_config" + DROP CONSTRAINT "promo_streak_config_eligible_products_set"`); + for (const statement of sqlOf(ADDED_IN)) { + await db.drizzle.db.execute(sql.raw(statement)); + } +} + +async function backfill() { + for (const statement of BACKFILL_SQL) { await db.drizzle.db.execute(sql.raw(statement)); } } @@ -74,7 +84,8 @@ async function upgradeFromBeforeProductLists() { describe('rank perk product lists on upgrade', () => { it('keeps every product on a ladder configured before the lists existed', async () => { await seedRankLadder(db.drizzle.db, LADDER); - await upgradeFromBeforeProductLists(); + await addProductLists(); + await backfill(); const userId = randomUUID(); const [tier] = await db.drizzle.db .select({ id: promoRankTier.id }) @@ -97,32 +108,31 @@ describe('rank perk product lists on upgrade', () => { const [config] = await db.drizzle.db.select().from(promoRankConfig); expect(config).toMatchObject({ - rakebackEligibleProducts: [], - rankChallengeEligibleProducts: [], + rakebackEligibleProducts: EVERY_PRODUCT, + rankChallengeEligibleProducts: EVERY_PRODUCT, }); expect(credit).toHaveBeenCalledTimes(1); }); it('keeps a list the operator saved when the backfill runs after it', async () => { - await upgradeFromBeforeProductLists(); + await addProductLists(); await seedRankLadder(db.drizzle.db, LADDER); await db.drizzle.db .update(promoRankConfig) .set({ rakebackEligibleProducts: ['casino', 'sportsbook'] }); - for (const statement of sqlOf('0019_rank_perk_products_backfill')) { - await db.drizzle.db.execute(sql.raw(statement)); - } + await backfill(); const [config] = await db.drizzle.db.select().from(promoRankConfig); expect(config).toMatchObject({ rakebackEligibleProducts: ['casino', 'sportsbook'], - rankChallengeEligibleProducts: [], + rankChallengeEligibleProducts: EVERY_PRODUCT, }); }); it('gives a ladder configured after the upgrade the narrow default', async () => { - await upgradeFromBeforeProductLists(); + await addProductLists(); + await backfill(); await seedRankLadder(db.drizzle.db, LADDER); const [config] = await db.drizzle.db.select().from(promoRankConfig); diff --git a/packages/core/src/promo/gamification/__tests__/rank.int.test.ts b/packages/core/src/promo/gamification/__tests__/rank.int.test.ts index cf20d398a..22617629e 100644 --- a/packages/core/src/promo/gamification/__tests__/rank.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rank.int.test.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { createTestDb, type TestDb } from '@openora/core/testing'; import { makeAuditWriter, mock } from '../../../testing/mock.js'; -import type { ExchangeRateReader, WagerContext } from '@openora/core/contracts'; +import type { ExchangeRateReader, WagerContext, WagerProduct } from '@openora/core/contracts'; import { migrate } from '../migrate.js'; import { promoPlayerRank, @@ -43,7 +43,7 @@ const LADDER = { }, ], config: { - eligibleProducts: [], + eligibleProducts: ['casino', 'live-casino', 'sportsbook', 'pvp'] satisfies WagerProduct[], rewards: {}, payoutAnchors: DEFAULT_PAYOUT_ANCHORS, payInPlayerCurrency: false, diff --git a/packages/core/src/promo/gamification/__tests__/streak-config.test.ts b/packages/core/src/promo/gamification/__tests__/streak-config.test.ts index e441207e5..3a2b0c306 100644 --- a/packages/core/src/promo/gamification/__tests__/streak-config.test.ts +++ b/packages/core/src/promo/gamification/__tests__/streak-config.test.ts @@ -24,4 +24,14 @@ describe('setting the streak config', () => { expect(result.success).toBe(false); expect(result.error?.issues[0]?.path).toEqual(['milestones']); }); + + it.each([ + ['no product', []], + ['a repeated product', ['casino', 'casino']], + ['an unknown product', ['slots']], + ])('rejects a product list with %s', (_, eligibleProducts) => { + const result = SetStreakConfigInputSchema.safeParse({ ...config(3), eligibleProducts }); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path.slice(0, 1)).toEqual(['eligibleProducts']); + }); }); diff --git a/packages/core/src/promo/gamification/__tests__/streak-payout.int.test.ts b/packages/core/src/promo/gamification/__tests__/streak-payout.int.test.ts index 3cb6802dd..701765c8e 100644 --- a/packages/core/src/promo/gamification/__tests__/streak-payout.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/streak-payout.int.test.ts @@ -116,7 +116,7 @@ beforeEach(async () => { await db.drizzle.db.insert(promoStreakConfig).values({ currency: 'USD', dailyMinWager: '1', - eligibleProducts: [], + eligibleProducts: ['casino', 'live-casino', 'sportsbook', 'pvp'], milestones: MILESTONES, resetAfterDay: 30, }); diff --git a/packages/core/src/promo/gamification/contract/index.ts b/packages/core/src/promo/gamification/contract/index.ts index f72af18ed..643d83591 100644 --- a/packages/core/src/promo/gamification/contract/index.ts +++ b/packages/core/src/promo/gamification/contract/index.ts @@ -156,9 +156,13 @@ export const DEFAULT_RANK_PERK_PRODUCTS = [ 'pvp', ] as const satisfies readonly WagerProduct[]; -/** Empty counts every product. */ const RankPerkProductsSchema = z.array(z.enum(WAGER_PRODUCTS)).max(WAGER_PRODUCTS.length); +const EligibleProductsInputSchema = z + .array(WagerProductSchema) + .min(1, 'select at least one product') + .refine((products) => new Set(products).size === products.length, 'products must not repeat'); + export const RankConfigSchema = z.object({ /** * What a rank reward is actually credited in, when that differs from the ladder's own @@ -191,7 +195,7 @@ export const RankConfigSchema = z.object({ isAbsentOrPositive, 'must be above zero', ).nullish(), - /** Products whose stakes count toward a rank. Empty counts every product. */ + /** Products whose stakes count toward a rank. */ eligibleProducts: z.array(z.string().trim().min(1).max(64)).max(50), /** Products whose stakes earn rakeback. A stake must count toward the rank as well. */ rakebackEligibleProducts: RankPerkProductsSchema, @@ -215,8 +219,9 @@ export type RankConfig = z.infer; * `periodicMinimumWager` left out keeps what is stored, or `null`. */ export const SetRankConfigInputSchema = RankConfigSchema.extend({ - rakebackEligibleProducts: RankPerkProductsSchema.optional(), - rankChallengeEligibleProducts: RankPerkProductsSchema.optional(), + eligibleProducts: EligibleProductsInputSchema, + rakebackEligibleProducts: EligibleProductsInputSchema.optional(), + rankChallengeEligibleProducts: EligibleProductsInputSchema.optional(), }); export type SetRankConfigInput = z.infer; export type RankConfigInput = z.input; @@ -262,7 +267,7 @@ export const StreakConfigSchema = z.object({ /** What counts toward a qualifying day, and in what currency the threshold is priced. */ currency: CurrencyTickerSchema, dailyMinWager: MoneyAmountSchema.refine(isAbsentOrPositive, 'must be above zero'), - /** Products whose stakes count toward the streak. Empty counts every product. */ + /** Products whose stakes count toward the streak. */ eligibleProducts: z.array(z.string().trim().min(1).max(64)).max(50), milestones: z .array(StreakMilestoneSchema) @@ -280,13 +285,16 @@ export type StreakConfig = z.infer; * A run resets once it reaches `resetAfterDay`, so a milestone past it could never be reached - * an operator saving one would be promising a reward nobody can earn. */ -export const SetStreakConfigInputSchema = StreakConfigSchema.refine( +export const SetStreakConfigInputSchema = StreakConfigSchema.extend({ + eligibleProducts: EligibleProductsInputSchema, +}).refine( (config) => config.milestones.every((milestone) => milestone.day <= config.resetAfterDay), { message: 'a milestone falls after resetAfterDay and can never be reached', path: ['milestones'], }, ); +export type StreakConfigInput = z.input; export const PlayerStreakSchema = z.object({ current: z.number().int().nonnegative(), @@ -373,11 +381,6 @@ export type RacePositions = z.infer; export const RaceEligibleProductsSchema = z.array(z.string().trim().min(1).max(64)).max(50); export type RaceEligibleProducts = z.infer; -const RaceEligibleProductsInputSchema = z - .array(WagerProductSchema) - .min(1, 'select at least one product') - .refine((products) => new Set(products).size === products.length, 'products must not repeat'); - const RaceFieldsShape = { name: z.string().min(1).max(200), currency: CurrencyTickerSchema, @@ -389,7 +392,7 @@ const RaceFieldsShape = { eligibleProducts: RaceEligibleProductsSchema, } as const; -const RaceInputShape = { ...RaceFieldsShape, eligibleProducts: RaceEligibleProductsInputSchema }; +const RaceInputShape = { ...RaceFieldsShape, eligibleProducts: EligibleProductsInputSchema }; const raceDatesOrdered = (v: { startAt: string; endAt: string }) => new Date(v.endAt).getTime() > new Date(v.startAt).getTime(); diff --git a/packages/core/src/promo/gamification/drizzle/migrations/0022_rank_streak_products_required.sql b/packages/core/src/promo/gamification/drizzle/migrations/0022_rank_streak_products_required.sql new file mode 100644 index 000000000..356322b26 --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/0022_rank_streak_products_required.sql @@ -0,0 +1,16 @@ +ALTER TABLE "promo_rank_config" ALTER COLUMN "eligible_products" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "promo_streak_config" ALTER COLUMN "eligible_products" DROP DEFAULT;--> statement-breakpoint +-- An empty list used to count every product; it now has to name them, so a config saved before +-- keeps counting what it did. +UPDATE "promo_rank_config" SET "eligible_products" = '{"casino","live-casino","sportsbook","pvp"}' +WHERE cardinality("eligible_products") = 0;--> statement-breakpoint +UPDATE "promo_rank_config" SET "rakeback_eligible_products" = '{"casino","live-casino","sportsbook","pvp"}' +WHERE cardinality("rakeback_eligible_products") = 0;--> statement-breakpoint +UPDATE "promo_rank_config" SET "rank_challenge_eligible_products" = '{"casino","live-casino","sportsbook","pvp"}' +WHERE cardinality("rank_challenge_eligible_products") = 0;--> statement-breakpoint +UPDATE "promo_streak_config" SET "eligible_products" = '{"casino","live-casino","sportsbook","pvp"}' +WHERE cardinality("eligible_products") = 0;--> statement-breakpoint +ALTER TABLE "promo_rank_config" ADD CONSTRAINT "promo_rank_config_eligible_products_set" CHECK (cardinality("promo_rank_config"."eligible_products") > 0);--> statement-breakpoint +ALTER TABLE "promo_rank_config" ADD CONSTRAINT "promo_rank_config_rakeback_eligible_products_set" CHECK (cardinality("promo_rank_config"."rakeback_eligible_products") > 0);--> statement-breakpoint +ALTER TABLE "promo_rank_config" ADD CONSTRAINT "promo_rank_config_rank_challenge_eligible_products_set" CHECK (cardinality("promo_rank_config"."rank_challenge_eligible_products") > 0);--> statement-breakpoint +ALTER TABLE "promo_streak_config" ADD CONSTRAINT "promo_streak_config_eligible_products_set" CHECK (cardinality("promo_streak_config"."eligible_products") > 0); \ No newline at end of file diff --git a/packages/core/src/promo/gamification/drizzle/migrations/meta/0022_snapshot.json b/packages/core/src/promo/gamification/drizzle/migrations/meta/0022_snapshot.json new file mode 100644 index 000000000..dc16226b7 --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/0022_snapshot.json @@ -0,0 +1,2353 @@ +{ + "id": "2dfa83a4-7f9c-4dd5-b2ea-65dba84348d8", + "prevId": "8b2b9593-414d-4801-810d-5501646ba100", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.promo_player_rank": { + "name": "promo_player_rank", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifetime_wagered": { + "name": "lifetime_wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "tier_id": { + "name": "tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "announced_tier_id": { + "name": "announced_tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_wagered_at": { + "name": "last_wagered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rakeback_boost_percent": { + "name": "rakeback_boost_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "rakeback_boost_expires_at": { + "name": "rakeback_boost_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_claimable_notified_at": { + "name": "last_claimable_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rakeback_reminder_checked_at": { + "name": "rakeback_reminder_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_player_rank_unannounced_idx": { + "name": "promo_player_rank_unannounced_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_player_rank\".\"announced_tier_id\" IS DISTINCT FROM \"promo_player_rank\".\"tier_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_player_rank_tier_id_promo_rank_tier_id_fk": { + "name": "promo_player_rank_tier_id_promo_rank_tier_id_fk", + "tableFrom": "promo_player_rank", + "tableTo": "promo_rank_tier", + "columnsFrom": ["tier_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_player_rank_userId_unique": { + "name": "promo_player_rank_userId_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_player_rank_lifetime_wagered_non_negative": { + "name": "promo_player_rank_lifetime_wagered_non_negative", + "value": "\"promo_player_rank\".\"lifetime_wagered\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_player_streak": { + "name": "promo_player_streak", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current": { + "name": "current", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "best": { + "name": "best", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_qualifying_day": { + "name": "last_qualifying_day", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_player_streak_userId_unique": { + "name": "promo_player_streak_userId_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_player_streak_counts_non_negative": { + "name": "promo_player_streak_counts_non_negative", + "value": "\"promo_player_streak\".\"current\" >= 0 AND \"promo_player_streak\".\"best\" >= 0 AND \"promo_player_streak\".\"current\" <= \"promo_player_streak\".\"best\"" + } + }, + "isRLSEnabled": false + }, + "public.promo_race": { + "name": "promo_race", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "prize_pool": { + "name": "prize_pool", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "positions": { + "name": "positions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "eligible_products": { + "name": "eligible_products", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_race_open_idx": { + "name": "promo_race_open_idx", + "columns": [ + { + "expression": "start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "end_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_race\".\"closed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_race_prize_pool_positive": { + "name": "promo_race_prize_pool_positive", + "value": "\"promo_race\".\"prize_pool\" > 0" + }, + "promo_race_dates_ordered": { + "name": "promo_race_dates_ordered", + "value": "\"promo_race\".\"end_at\" > \"promo_race\".\"start_at\"" + }, + "promo_race_eligible_products_set": { + "name": "promo_race_eligible_products_set", + "value": "cardinality(\"promo_race\".\"eligible_products\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_race_payout": { + "name": "promo_race_payout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "race_id": { + "name": "race_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "promo_race_payout_race_id_user_id_idx": { + "name": "promo_race_payout_race_id_user_id_idx", + "columns": [ + { + "expression": "race_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_race_payout_race_id_idx": { + "name": "promo_race_payout_race_id_idx", + "columns": [ + { + "expression": "race_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_race_payout_race_id_promo_race_id_fk": { + "name": "promo_race_payout_race_id_promo_race_id_fk", + "tableFrom": "promo_race_payout", + "tableTo": "promo_race", + "columnsFrom": ["race_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_race_payout_position_positive": { + "name": "promo_race_payout_position_positive", + "value": "\"promo_race_payout\".\"position\" > 0" + }, + "promo_race_payout_amount_non_negative": { + "name": "promo_race_payout_amount_non_negative", + "value": "\"promo_race_payout\".\"amount\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_race_round_wager": { + "name": "promo_race_round_wager", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "race_id": { + "name": "race_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_round_id": { + "name": "external_round_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stake": { + "name": "stake", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "wagered": { + "name": "wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "promo_race_round_wager_round_idx": { + "name": "promo_race_round_wager_round_idx", + "columns": [ + { + "expression": "race_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_race_round_wager_user_id_external_round_id_idx": { + "name": "promo_race_round_wager_user_id_external_round_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_race_round_wager_race_id_promo_race_id_fk": { + "name": "promo_race_round_wager_race_id_promo_race_id_fk", + "tableFrom": "promo_race_round_wager", + "tableTo": "promo_race", + "columnsFrom": ["race_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_race_round_wager_non_negative": { + "name": "promo_race_round_wager_non_negative", + "value": "\"promo_race_round_wager\".\"stake\" >= 0 AND \"promo_race_round_wager\".\"wagered\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_race_wager": { + "name": "promo_race_wager", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "race_id": { + "name": "race_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wagered": { + "name": "wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_race_wager_race_id_user_id_idx": { + "name": "promo_race_wager_race_id_user_id_idx", + "columns": [ + { + "expression": "race_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_race_wager_race_id_wagered_idx": { + "name": "promo_race_wager_race_id_wagered_idx", + "columns": [ + { + "expression": "race_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "wagered", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_race_wager_race_id_promo_race_id_fk": { + "name": "promo_race_wager_race_id_promo_race_id_fk", + "tableFrom": "promo_race_wager", + "tableTo": "promo_race", + "columnsFrom": ["race_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_race_wager_non_negative": { + "name": "promo_race_wager_non_negative", + "value": "\"promo_race_wager\".\"wagered\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_rakeback_accrual": { + "name": "promo_rakeback_accrual", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "stake": { + "name": "stake", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "rolled_back_stake": { + "name": "rolled_back_stake", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "basis": { + "name": "basis", + "type": "promo_rakeback_basis", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "rtp": { + "name": "rtp", + "type": "numeric(19, 18)", + "primaryKey": false, + "notNull": false + }, + "rate_percent": { + "name": "rate_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "boost_percent": { + "name": "boost_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_game_key": { + "name": "provider_game_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_round_id": { + "name": "external_round_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "promo_rakeback_accrual_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'accrued'" + }, + "claim_id": { + "name": "claim_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "voided_by": { + "name": "voided_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "void_reason": { + "name": "void_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "promo_rakeback_accrual_user_id_status_idx": { + "name": "promo_rakeback_accrual_user_id_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rakeback_accrual_round_idx": { + "name": "promo_rakeback_accrual_round_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rakeback_accrual_claim_id_idx": { + "name": "promo_rakeback_accrual_claim_id_idx", + "columns": [ + { + "expression": "claim_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rakeback_accrual_unclaimed_idx": { + "name": "promo_rakeback_accrual_unclaimed_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_rakeback_accrual\".\"status\" = 'accrued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rakeback_accrual_created_at_idx": { + "name": "promo_rakeback_accrual_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_rakeback_accrual_claim_id_promo_rakeback_claim_id_fk": { + "name": "promo_rakeback_accrual_claim_id_promo_rakeback_claim_id_fk", + "tableFrom": "promo_rakeback_accrual", + "tableTo": "promo_rakeback_claim", + "columnsFrom": ["claim_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_rakeback_accrual_bounds": { + "name": "promo_rakeback_accrual_bounds", + "value": "\"promo_rakeback_accrual\".\"amount\" > 0 AND \"promo_rakeback_accrual\".\"stake\" >= 0 AND \"promo_rakeback_accrual\".\"rolled_back_stake\" >= 0 AND \"promo_rakeback_accrual\".\"rolled_back_stake\" <= \"promo_rakeback_accrual\".\"stake\" AND (\"promo_rakeback_accrual\".\"rtp\" is null OR (\"promo_rakeback_accrual\".\"rtp\" > 0 AND \"promo_rakeback_accrual\".\"rtp\" <= 1))" + }, + "promo_rakeback_accrual_settlement": { + "name": "promo_rakeback_accrual_settlement", + "value": "(\"promo_rakeback_accrual\".\"status\" = 'claimed') = (\"promo_rakeback_accrual\".\"claim_id\" is not null)\n AND (\"promo_rakeback_accrual\".\"status\" = 'voided') = (\"promo_rakeback_accrual\".\"void_reason\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.promo_rakeback_claim": { + "name": "promo_rakeback_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "promo_rakeback_claim_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_rakeback_claim_user_id_idempotency_key_idx": { + "name": "promo_rakeback_claim_user_id_idempotency_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promo_rakeback_claim_payout": { + "name": "promo_rakeback_claim_payout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "claim_id": { + "name": "claim_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "transaction_id": { + "name": "transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "promo_rakeback_claim_payout_claim_id_currency_idx": { + "name": "promo_rakeback_claim_payout_claim_id_currency_idx", + "columns": [ + { + "expression": "claim_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rakeback_claim_payout_user_id_currency_idx": { + "name": "promo_rakeback_claim_payout_user_id_currency_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_rakeback_claim_payout_claim_id_promo_rakeback_claim_id_fk": { + "name": "promo_rakeback_claim_payout_claim_id_promo_rakeback_claim_id_fk", + "tableFrom": "promo_rakeback_claim_payout", + "tableTo": "promo_rakeback_claim", + "columnsFrom": ["claim_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_rakeback_claim_payout_amount_positive": { + "name": "promo_rakeback_claim_payout_amount_positive", + "value": "\"promo_rakeback_claim_payout\".\"amount\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_rank_challenge_claim": { + "name": "promo_rank_challenge_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tier_id": { + "name": "tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cash_amount": { + "name": "cash_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "physical_item": { + "name": "physical_item", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cash_grant_id": { + "name": "cash_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "physical_fulfilled_at": { + "name": "physical_fulfilled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "physical_fulfilled_by": { + "name": "physical_fulfilled_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "physical_fulfillment_note": { + "name": "physical_fulfillment_note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "promo_rank_challenge_claim_tier_id_idx": { + "name": "promo_rank_challenge_claim_tier_id_idx", + "columns": [ + { + "expression": "tier_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rank_challenge_claim_unsettled_idx": { + "name": "promo_rank_challenge_claim_unsettled_idx", + "columns": [ + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_rank_challenge_claim\".\"settled_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rank_challenge_claim_fulfilment_queue_idx": { + "name": "promo_rank_challenge_claim_fulfilment_queue_idx", + "columns": [ + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_rank_challenge_claim\".\"physical_item\" is not null AND \"promo_rank_challenge_claim\".\"physical_fulfilled_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promo_rank_challenge_tier": { + "name": "promo_rank_challenge_tier", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wager_threshold": { + "name": "wager_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "cash_amount": { + "name": "cash_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "physical_item": { + "name": "physical_item", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_rank_challenge_tier_key_unique": { + "name": "promo_rank_challenge_tier_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + }, + "promo_rank_challenge_tier_position_unique": { + "name": "promo_rank_challenge_tier_position_unique", + "nullsNotDistinct": false, + "columns": ["position"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_rank_challenge_tier_bounds": { + "name": "promo_rank_challenge_tier_bounds", + "value": "\"promo_rank_challenge_tier\".\"position\" >= 0 AND \"promo_rank_challenge_tier\".\"wager_threshold\" >= 0\n AND (\"promo_rank_challenge_tier\".\"cash_amount\" is null OR \"promo_rank_challenge_tier\".\"cash_amount\" > 0)\n AND (\"promo_rank_challenge_tier\".\"physical_item\" is not null OR \"promo_rank_challenge_tier\".\"cash_amount\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.promo_rank_challenge_wager": { + "name": "promo_rank_challenge_wager", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifetime_wagered": { + "name": "lifetime_wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_rank_challenge_wager_userId_unique": { + "name": "promo_rank_challenge_wager_userId_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_rank_challenge_wager_non_negative": { + "name": "promo_rank_challenge_wager_non_negative", + "value": "\"promo_rank_challenge_wager\".\"lifetime_wagered\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_rank_config": { + "name": "promo_rank_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "eligible_products": { + "name": "eligible_products", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "rakeback_eligible_products": { + "name": "rakeback_eligible_products", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"casino\",\"live-casino\",\"pvp\"}'" + }, + "rank_challenge_eligible_products": { + "name": "rank_challenge_eligible_products", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"casino\",\"live-casino\",\"pvp\"}'" + }, + "rewards": { + "name": "rewards", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "payout_currency": { + "name": "payout_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pay_in_player_currency": { + "name": "pay_in_player_currency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "periodic_requires_activity": { + "name": "periodic_requires_activity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "periodic_minimum_wager": { + "name": "periodic_minimum_wager", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "payout_anchors": { + "name": "payout_anchors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"dailyHour\":0,\"weeklyDay\":1,\"monthlyDay\":1}'::jsonb" + }, + "paid_through": { + "name": "paid_through", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_rank_config_singletonKey_unique": { + "name": "promo_rank_config_singletonKey_unique", + "nullsNotDistinct": false, + "columns": ["singleton_key"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_rank_config_eligible_products_set": { + "name": "promo_rank_config_eligible_products_set", + "value": "cardinality(\"promo_rank_config\".\"eligible_products\") > 0" + }, + "promo_rank_config_rakeback_eligible_products_set": { + "name": "promo_rank_config_rakeback_eligible_products_set", + "value": "cardinality(\"promo_rank_config\".\"rakeback_eligible_products\") > 0" + }, + "promo_rank_config_rank_challenge_eligible_products_set": { + "name": "promo_rank_config_rank_challenge_eligible_products_set", + "value": "cardinality(\"promo_rank_config\".\"rank_challenge_eligible_products\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_rank_level_up": { + "name": "promo_rank_level_up", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tier_id": { + "name": "tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "reached_at": { + "name": "reached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "promo_rank_level_up_user_id_tier_id_idx": { + "name": "promo_rank_level_up_user_id_tier_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rank_level_up_unsettled_idx": { + "name": "promo_rank_level_up_unsettled_idx", + "columns": [ + { + "expression": "reached_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_rank_level_up\".\"settled_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_rank_level_up_amount_positive": { + "name": "promo_rank_level_up_amount_positive", + "value": "\"promo_rank_level_up\".\"amount\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_rank_period_wager": { + "name": "promo_rank_period_wager", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_key": { + "name": "period_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wagered": { + "name": "wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_rank_period_wager_user_id_kind_period_key_idx": { + "name": "promo_rank_period_wager_user_id_kind_period_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rank_period_wager_kind_period_key_idx": { + "name": "promo_rank_period_wager_kind_period_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rank_period_wager_kind_updated_at_idx": { + "name": "promo_rank_period_wager_kind_updated_at_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_rank_period_wager_non_negative": { + "name": "promo_rank_period_wager_non_negative", + "value": "\"promo_rank_period_wager\".\"wagered\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_rank_tier": { + "name": "promo_rank_tier", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wager_threshold": { + "name": "wager_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "rakeback_percent": { + "name": "rakeback_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "daily_bonus": { + "name": "daily_bonus", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "weekly_bonus": { + "name": "weekly_bonus", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "monthly_bonus": { + "name": "monthly_bonus", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "level_up_bonus": { + "name": "level_up_bonus", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_rank_tier_key_unique": { + "name": "promo_rank_tier_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_rank_tier_bounds": { + "name": "promo_rank_tier_bounds", + "value": "\"promo_rank_tier\".\"position\" >= 0 AND \"promo_rank_tier\".\"wager_threshold\" >= 0\n AND \"promo_rank_tier\".\"rakeback_percent\" >= 0 AND \"promo_rank_tier\".\"rakeback_percent\" <= 100\n AND (\"promo_rank_tier\".\"daily_bonus\" is null OR \"promo_rank_tier\".\"daily_bonus\" > 0)\n AND (\"promo_rank_tier\".\"weekly_bonus\" is null OR \"promo_rank_tier\".\"weekly_bonus\" > 0)\n AND (\"promo_rank_tier\".\"monthly_bonus\" is null OR \"promo_rank_tier\".\"monthly_bonus\" > 0)\n AND (\"promo_rank_tier\".\"level_up_bonus\" is null OR \"promo_rank_tier\".\"level_up_bonus\" > 0)" + } + }, + "isRLSEnabled": false + }, + "public.promo_streak_config": { + "name": "promo_streak_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "daily_min_wager": { + "name": "daily_min_wager", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "eligible_products": { + "name": "eligible_products", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reset_after_day": { + "name": "reset_after_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_streak_config_singletonKey_unique": { + "name": "promo_streak_config_singletonKey_unique", + "nullsNotDistinct": false, + "columns": ["singleton_key"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_streak_config_eligible_products_set": { + "name": "promo_streak_config_eligible_products_set", + "value": "cardinality(\"promo_streak_config\".\"eligible_products\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_streak_daily_wager": { + "name": "promo_streak_daily_wager", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wagered": { + "name": "wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_streak_daily_wager_user_id_day_idx": { + "name": "promo_streak_daily_wager_user_id_day_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_streak_daily_wager_non_negative": { + "name": "promo_streak_daily_wager_non_negative", + "value": "\"promo_streak_daily_wager\".\"wagered\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_streak_milestone_grant": { + "name": "promo_streak_milestone_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reached_at": { + "name": "reached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "announced_at": { + "name": "announced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "promo_streak_milestone_grant_user_id_day_idx": { + "name": "promo_streak_milestone_grant_user_id_day_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_streak_milestone_grant_unsettled_idx": { + "name": "promo_streak_milestone_grant_unsettled_idx", + "columns": [ + { + "expression": "reached_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_streak_milestone_grant\".\"settled_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_streak_milestone_grant_day_positive": { + "name": "promo_streak_milestone_grant_day_positive", + "value": "\"promo_streak_milestone_grant\".\"day\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_streak_round_wager": { + "name": "promo_streak_round_wager", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_round_id": { + "name": "external_round_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "stake": { + "name": "stake", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "wagered": { + "name": "wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "promo_streak_round_wager_round_idx": { + "name": "promo_streak_round_wager_round_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_streak_round_wager_day_idx": { + "name": "promo_streak_round_wager_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_streak_round_wager_non_negative": { + "name": "promo_streak_round_wager_non_negative", + "value": "\"promo_streak_round_wager\".\"stake\" >= 0 AND \"promo_streak_round_wager\".\"wagered\" >= 0" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.promo_rakeback_accrual_status": { + "name": "promo_rakeback_accrual_status", + "schema": "public", + "values": ["accrued", "claimed", "reversed", "voided"] + }, + "public.promo_rakeback_basis": { + "name": "promo_rakeback_basis", + "schema": "public", + "values": ["stake", "margin"] + }, + "public.promo_rakeback_claim_outcome": { + "name": "promo_rakeback_claim_outcome", + "schema": "public", + "values": ["claimed", "auto_paid"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json b/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json index 03660a20e..88e77e33e 100644 --- a/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1793630935027, "tag": "0021_rakeback_claim", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1793808578400, + "tag": "0022_rank_streak_products_required", + "breakpoints": true } ] } diff --git a/packages/core/src/promo/gamification/schema/index.ts b/packages/core/src/promo/gamification/schema/index.ts index 53a504b03..657f00972 100644 --- a/packages/core/src/promo/gamification/schema/index.ts +++ b/packages/core/src/promo/gamification/schema/index.ts @@ -133,52 +133,66 @@ export type RankRewardTerms = NonNullable; * operator who has not decided what counts toward a rank has not launched ranks. The rank * challenge is not part of the ladder: absent, it counts `DEFAULT_RANK_PERK_PRODUCTS`. */ -export const promoRankConfig = pgTable('promo_rank_config', { - id: uuid().primaryKey().defaultRandom(), - // Unique, so the table can only ever hold the one row. - singletonKey: text().notNull().unique().default('global'), - /** Products whose stakes count toward a rank. Empty counts every product. */ - eligibleProducts: text().array().notNull().default([]), - /** Products whose stakes earn rakeback. A stake must count toward the rank as well. Empty counts every product. */ - rakebackEligibleProducts: text() - .array() - .$type() - .notNull() - .default([...DEFAULT_RANK_PERK_PRODUCTS]), - /** Products whose stakes count toward the rank challenge. Empty counts every product. */ - rankChallengeEligibleProducts: text() - .array() - .$type() - .notNull() - .default([...DEFAULT_RANK_PERK_PRODUCTS]), - /** Terms each reward kind is granted under. A kind with no terms is not paid. */ - rewards: jsonb().$type().notNull().default({}), - /** What rewards are credited in, when that is not the ladder's own currency. */ - payoutCurrency: text(), - /** Credit a reward in the currency the player plays in, falling back to the two above. */ - payInPlayerCurrency: boolean().notNull().default(false), - /** Pay a periodic bonus only to players who wagered in the period it covers. */ - periodicRequiresActivity: boolean().notNull().default(true), - /** How much a player must wager inside the period to qualify. Null means any bet counts. */ - periodicMinimumWager: money(), - /** When each periodic payout closes, and so what window it pays for. All UTC. */ - payoutAnchors: jsonb() - .$type() - .notNull() - .default({ dailyHour: 0, weeklyDay: 1, monthlyDay: 1 }), - /** - * The end of the last period each kind was paid for. A payout runs only for a period that - * ends after its watermark, so moving an anchor cannot pay the same stretch of time twice and - * a late or repeated tick cannot reach back into a period already settled. - */ - paidThrough: jsonb().$type>>().notNull().default({}), - updatedBy: uuid(), - createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ withTimezone: true }) - .notNull() - .defaultNow() - .$onUpdate(() => new Date()), -}); +export const promoRankConfig = pgTable( + 'promo_rank_config', + { + id: uuid().primaryKey().defaultRandom(), + // Unique, so the table can only ever hold the one row. + singletonKey: text().notNull().unique().default('global'), + /** Products whose stakes count toward a rank. */ + eligibleProducts: text().array().notNull(), + /** Products whose stakes earn rakeback. A stake must count toward the rank as well. */ + rakebackEligibleProducts: text() + .array() + .$type() + .notNull() + .default([...DEFAULT_RANK_PERK_PRODUCTS]), + /** Products whose stakes count toward the rank challenge. */ + rankChallengeEligibleProducts: text() + .array() + .$type() + .notNull() + .default([...DEFAULT_RANK_PERK_PRODUCTS]), + /** Terms each reward kind is granted under. A kind with no terms is not paid. */ + rewards: jsonb().$type().notNull().default({}), + /** What rewards are credited in, when that is not the ladder's own currency. */ + payoutCurrency: text(), + /** Credit a reward in the currency the player plays in, falling back to the two above. */ + payInPlayerCurrency: boolean().notNull().default(false), + /** Pay a periodic bonus only to players who wagered in the period it covers. */ + periodicRequiresActivity: boolean().notNull().default(true), + /** How much a player must wager inside the period to qualify. Null means any bet counts. */ + periodicMinimumWager: money(), + /** When each periodic payout closes, and so what window it pays for. All UTC. */ + payoutAnchors: jsonb() + .$type() + .notNull() + .default({ dailyHour: 0, weeklyDay: 1, monthlyDay: 1 }), + /** + * The end of the last period each kind was paid for. A payout runs only for a period that + * ends after its watermark, so moving an anchor cannot pay the same stretch of time twice and + * a late or repeated tick cannot reach back into a period already settled. + */ + paidThrough: jsonb().$type>>().notNull().default({}), + updatedBy: uuid(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [ + check('promo_rank_config_eligible_products_set', sql`cardinality(${t.eligibleProducts}) > 0`), + check( + 'promo_rank_config_rakeback_eligible_products_set', + sql`cardinality(${t.rakebackEligibleProducts}) > 0`, + ), + check( + 'promo_rank_config_rank_challenge_eligible_products_set', + sql`cardinality(${t.rankChallengeEligibleProducts}) > 0`, + ), + ], +); export type PromoRankConfig = typeof promoRankConfig.$inferSelect; @@ -259,22 +273,28 @@ export type PromoRankLevelUp = typeof promoRankLevelUp.$inferSelect; * day counts and no milestone pays: an operator who has not decided what qualifies has not * launched the streak. */ -export const promoStreakConfig = pgTable('promo_streak_config', { - id: uuid().primaryKey().defaultRandom(), - singletonKey: text().notNull().unique().default('global'), - currency: text().notNull(), - dailyMinWager: money().notNull(), - /** Products whose stakes count toward the streak. Empty counts every product. */ - eligibleProducts: text().array().notNull().default([]), - milestones: jsonb().$type().notNull().default([]), - resetAfterDay: integer().notNull().default(30), - updatedBy: uuid(), - createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ withTimezone: true }) - .notNull() - .defaultNow() - .$onUpdate(() => new Date()), -}); +export const promoStreakConfig = pgTable( + 'promo_streak_config', + { + id: uuid().primaryKey().defaultRandom(), + singletonKey: text().notNull().unique().default('global'), + currency: text().notNull(), + dailyMinWager: money().notNull(), + /** Products whose stakes count toward the streak. */ + eligibleProducts: text().array().notNull(), + milestones: jsonb().$type().notNull().default([]), + resetAfterDay: integer().notNull().default(30), + updatedBy: uuid(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [ + check('promo_streak_config_eligible_products_set', sql`cardinality(${t.eligibleProducts}) > 0`), + ], +); export type PromoStreakConfig = typeof promoStreakConfig.$inferSelect; diff --git a/packages/core/src/promo/gamification/seed/index.ts b/packages/core/src/promo/gamification/seed/index.ts index 36f7bc3f2..780722212 100644 --- a/packages/core/src/promo/gamification/seed/index.ts +++ b/packages/core/src/promo/gamification/seed/index.ts @@ -1,5 +1,5 @@ import type { DrizzleDb, DrizzleTx } from '@openora/core/server'; -import type { RankConfigInput, StreakConfig } from '../contract/index.js'; +import type { RankConfigInput, StreakConfigInput } from '../contract/index.js'; import { promoRankChallengeTier, promoRankConfig, @@ -54,7 +54,7 @@ export async function seedRankLadder( await db.insert(promoRankConfig).values(ladder.config).onConflictDoNothing(); } -export type StreakSeed = StreakConfig; +export type StreakSeed = StreakConfigInput; /** * Seeds the streak config, mirroring `seedRankLadder`: idempotent, never overwrites a setting an diff --git a/packages/core/src/promo/gamification/service/rakeback.service.ts b/packages/core/src/promo/gamification/service/rakeback.service.ts index fff2d59ed..cba48de71 100644 --- a/packages/core/src/promo/gamification/service/rakeback.service.ts +++ b/packages/core/src/promo/gamification/service/rakeback.service.ts @@ -47,7 +47,7 @@ export type RakebackSettings = { basis: RakebackBasis; payout: RakebackPayout }; const DEFAULT_SETTINGS: RakebackSettings = { basis: 'stake', payout: 'instant' }; const countsToward = (eligibleProducts: readonly string[], product: string) => - eligibleProducts.length === 0 || eligibleProducts.includes(product); + eligibleProducts.includes(product); /** The part of a bet's own-money stake rakeback is a share of; zero when it cannot be priced. */ export function rakebackBase(realAmount: string, basis: RakebackBasis, rtp?: string): string { diff --git a/packages/core/src/promo/gamification/service/rank-challenge.service.ts b/packages/core/src/promo/gamification/service/rank-challenge.service.ts index 5fce3a500..01657cd0d 100644 --- a/packages/core/src/promo/gamification/service/rank-challenge.service.ts +++ b/packages/core/src/promo/gamification/service/rank-challenge.service.ts @@ -55,7 +55,7 @@ export class RankChallengeService implements WagerTrackingCommands { const products = promoRankConfig.rankChallengeEligibleProducts; const product = args.context.product; const challengeCountsProduct = sql`coalesce( - (select cardinality(${products}) = 0 or ${product} = any(${products}) + (select ${product} = any(${products}) from ${promoRankConfig} where ${promoRankConfig.singletonKey} = 'global'), ${DEFAULT_RANK_PERK_PRODUCTS.some((p) => p === product)}::boolean )`; diff --git a/packages/core/src/promo/gamification/service/rank.service.ts b/packages/core/src/promo/gamification/service/rank.service.ts index 61ef44b17..30ccd6e19 100644 --- a/packages/core/src/promo/gamification/service/rank.service.ts +++ b/packages/core/src/promo/gamification/service/rank.service.ts @@ -37,9 +37,8 @@ type LadderRung = Pick; const tierFor = (ladder: readonly T[], lifetime: string) => ladder.filter((tier) => moneyCompare(tier.wagerThreshold, lifetime) <= 0).at(-1); -// An empty list counts every bet. A bet always names its product, so there is no third case. const countsToward = (eligibleProducts: readonly string[], product: string) => - eligibleProducts.length === 0 || eligibleProducts.includes(product); + eligibleProducts.includes(product); export class RankService implements WagerTrackingCommands { constructor( diff --git a/packages/core/src/promo/gamification/service/streak.service.ts b/packages/core/src/promo/gamification/service/streak.service.ts index 393dd7fc9..c1b39a98e 100644 --- a/packages/core/src/promo/gamification/service/streak.service.ts +++ b/packages/core/src/promo/gamification/service/streak.service.ts @@ -27,9 +27,8 @@ import { roundReversal } from '../shared/round-reversal.js'; export const StreakConfigNotSetError = makeNotFoundError('StreakConfig'); -// An empty list counts every bet - the same convention `RankService` uses for eligibleProducts. const countsToward = (eligibleProducts: readonly string[], product: string) => - eligibleProducts.length === 0 || eligibleProducts.includes(product); + eligibleProducts.includes(product); const isoDate = (date: Date) => date.toISOString().slice(0, 10); const DAY_MS = 86_400_000; diff --git a/packages/testing/src/__tests__/promo-rank-config.e2e.test.ts b/packages/testing/src/__tests__/promo-rank-config.e2e.test.ts index 8598f05d8..f621890a2 100644 --- a/packages/testing/src/__tests__/promo-rank-config.e2e.test.ts +++ b/packages/testing/src/__tests__/promo-rank-config.e2e.test.ts @@ -169,11 +169,56 @@ describe('an operator configuring what a rank counts and pays', () => { expect(await auditRows()).toHaveLength(auditedBefore); }); + it.each([ + [ + 'an empty rank list', + { eligibleProducts: [] }, + ['eligibleProducts'], + 'select at least one product', + ], + [ + 'an empty rakeback list', + { rakebackEligibleProducts: [] }, + ['rakebackEligibleProducts'], + 'select at least one product', + ], + [ + 'an empty rank challenge list', + { rankChallengeEligibleProducts: [] }, + ['rankChallengeEligibleProducts'], + 'select at least one product', + ], + [ + 'a repeated product', + { eligibleProducts: ['casino', 'casino'] }, + ['eligibleProducts'], + 'products must not repeat', + ], + [ + 'an unknown rank product', + { eligibleProducts: ['slots'] }, + ['eligibleProducts', 0], + expect.any(String), + ], + ])('refuses %s, changing nothing', async (_, lists, path, message) => { + const auditedBefore = (await auditRows()).length; + + const res = await admin.put(CONFIG_PATH, { ...CASINO_ONLY, ...lists }); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + code: 'BAD_REQUEST', + data: { issues: [expect.objectContaining({ path, message })] }, + }); + expect(await readJson(await admin.get(CONFIG_PATH))).toEqual(SEEDED); + expect(await auditRows()).toHaveLength(auditedBefore); + }); + it('refuses a wagering multiplier the bonus engine would reject, changing nothing', async () => { const auditedBefore = (await auditRows()).length; const res = await admin.put(CONFIG_PATH, { - eligibleProducts: [], + eligibleProducts: ['casino'], rewards: { daily: { wageringMultiplier: '1000.01', expiryDays: 1 } }, payoutAnchors: EXAMPLE_RANK_LADDER.config.payoutAnchors, }); diff --git a/packages/testing/src/__tests__/promo-streak-claim.e2e.test.ts b/packages/testing/src/__tests__/promo-streak-claim.e2e.test.ts index d312409e4..4b73efcb9 100644 --- a/packages/testing/src/__tests__/promo-streak-claim.e2e.test.ts +++ b/packages/testing/src/__tests__/promo-streak-claim.e2e.test.ts @@ -80,7 +80,7 @@ beforeAll(async () => { .values({ currency: 'USD', dailyMinWager: '10', - eligibleProducts: [], + eligibleProducts: ['casino', 'live-casino', 'sportsbook', 'pvp'], milestones: [{ day: 1, rewards: [{ kind: 'cash', amount: '5' }] }], resetAfterDay: 30, }); diff --git a/packages/testing/src/__tests__/promo-streak-config.e2e.test.ts b/packages/testing/src/__tests__/promo-streak-config.e2e.test.ts new file mode 100644 index 000000000..960d0fd82 --- /dev/null +++ b/packages/testing/src/__tests__/promo-streak-config.e2e.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { DRIZZLE } from '@openora/core/server'; +import { promoStreakConfig } from '@openora/core/promo/schema/gamification'; +import { + asAdmin, + bootE2e, + closeE2e, + readJson, + type TestApp, + type TestClient, + type TestDb, +} from '../index.js'; + +const CONFIG_PATH = '/backoffice/promo/streaks/config'; +const CONFIG = { + currency: 'USD', + dailyMinWager: '10.000000000000000000', + eligibleProducts: ['casino', 'sportsbook'], + milestones: [{ day: 1, rewards: [{ kind: 'cash', amount: '5.000000000000000000' }] }], + resetAfterDay: 30, +}; + +let db: TestDb; +let app: TestApp; +let admin: TestClient; +let savedConfig: (typeof promoStreakConfig.$inferSelect)[] = []; + +const drizzle = () => app.container.get(DRIZZLE).db; + +beforeAll(async () => { + ({ db, app } = await bootE2e()); + admin = await asAdmin(app.app); + savedConfig = await drizzle().select().from(promoStreakConfig); +}, 60_000); + +afterAll(async () => { + await drizzle().delete(promoStreakConfig); + if (savedConfig.length > 0) { + await drizzle().insert(promoStreakConfig).values(savedConfig); + } + await closeE2e({ app, db }); +}); + +describe('an operator choosing which products count toward the streak', () => { + it('saves the products it names', async () => { + const res = await admin.put(CONFIG_PATH, CONFIG); + + expect(res.status).toBe(200); + expect(await readJson(await admin.get(CONFIG_PATH))).toMatchObject({ + eligibleProducts: ['casino', 'sportsbook'], + }); + }); + + it.each([ + ['an empty list', [], ['eligibleProducts'], 'select at least one product'], + ['a repeated product', ['casino', 'casino'], ['eligibleProducts'], 'products must not repeat'], + ['an unknown product', ['slots'], ['eligibleProducts', 0], expect.any(String)], + ])('refuses %s, changing nothing', async (_, eligibleProducts, path, message) => { + await admin.put(CONFIG_PATH, CONFIG); + + const res = await admin.put(CONFIG_PATH, { ...CONFIG, eligibleProducts }); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + code: 'BAD_REQUEST', + data: { issues: [expect.objectContaining({ path, message })] }, + }); + expect(await readJson(await admin.get(CONFIG_PATH))).toMatchObject({ + eligibleProducts: ['casino', 'sportsbook'], + }); + }); +}); diff --git a/packages/testing/src/__tests__/sportsbook-promotions.e2e.test.ts b/packages/testing/src/__tests__/sportsbook-promotions.e2e.test.ts index 0f4424f7c..b72681214 100644 --- a/packages/testing/src/__tests__/sportsbook-promotions.e2e.test.ts +++ b/packages/testing/src/__tests__/sportsbook-promotions.e2e.test.ts @@ -1312,7 +1312,7 @@ describe('wager tracking', () => { admin.put('/backoffice/promo/streaks/config', { currency: 'USD', dailyMinWager: '1000', - eligibleProducts: [], + eligibleProducts: ['casino', 'live-casino', 'sportsbook', 'pvp'], milestones: [], resetAfterDay: 30, }), diff --git a/packages/testing/src/seed.ts b/packages/testing/src/seed.ts index 28caf4257..eaa107398 100644 --- a/packages/testing/src/seed.ts +++ b/packages/testing/src/seed.ts @@ -16,6 +16,7 @@ import { DEFAULT_PAYOUT_ANCHORS, DEFAULT_RANK_PERK_PRODUCTS, } from '@openora/core/promo/contracts/gamification'; +import { WAGER_PRODUCTS } from '@openora/core/contracts'; import { user, session, account, verification } from '@openora/core/pam/schema/identity'; /** @@ -60,7 +61,7 @@ export const EXAMPLE_RANK_LADDER: RankLadderSeed = { payoutAnchors: DEFAULT_PAYOUT_ANCHORS, payInPlayerCurrency: false, periodicRequiresActivity: true, - eligibleProducts: [], + eligibleProducts: [...WAGER_PRODUCTS], rakebackEligibleProducts: [...DEFAULT_RANK_PERK_PRODUCTS], rankChallengeEligibleProducts: [...DEFAULT_RANK_PERK_PRODUCTS], rewards: { From f02e62518ed5dff1613df7754f733de1181ea8fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksandr=20Kapitu=C5=82a?= Date: Fri, 6 Nov 2026 11:42:36 +0100 Subject: [PATCH 2/2] fix(gamification): keep empty product lists readable through the rollout An instance on the previous release still accepts an empty list. With the CHECKs in 0022 its save failed with a 500, and without the empty branch a list it saved after the backfill counted nothing. 0022 now only backfills and drops the defaults; the CHECKs and the empty-list branch go in a follow-up once the previous release has drained. --- docs/modules/gamification.md | 7 +- .../eligible-products-backfill.int.test.ts | 71 ++++----- .../__tests__/rakeback.int.test.ts | 10 ++ .../__tests__/rank-challenge.int.test.ts | 11 ++ .../rank-perk-products-backfill.int.test.ts | 5 +- .../0022_rank_streak_products_required.sql | 6 +- .../migrations/meta/0022_snapshot.json | 22 +-- .../src/promo/gamification/schema/index.ts | 144 ++++++++---------- .../gamification/service/rakeback.service.ts | 2 +- .../service/rank-challenge.service.ts | 2 +- .../gamification/service/rank.service.ts | 3 +- .../gamification/service/streak.service.ts | 3 +- 12 files changed, 127 insertions(+), 159 deletions(-) diff --git a/docs/modules/gamification.md b/docs/modules/gamification.md index 555befdb0..609fd044c 100644 --- a/docs/modules/gamification.md +++ b/docs/modules/gamification.md @@ -48,8 +48,11 @@ list and the rakeback list admit. The rank challenge does not need the ladder's without one it counts the default list. Every product list - the rank's, rakeback's, the rank challenge's and the streak's - names its products, and a save with an empty list, a repeated product or an unknown one is refused. An empty list used to count every product, so a list saved -empty before that rule was given all four on upgrade and keeps counting what it did. A list -naming only unknown products was kept and counts nothing until the operator saves a valid one. +empty before that rule was given all four on upgrade and keeps counting what it did. An instance +still on the previous release can save an empty list during a rolling upgrade, so a stored empty +list still counts every product; a later release backfills those and refuses them in the +database. A list naming only unknown products was kept and counts nothing until the operator +saves a valid one. Both lists are set on `PUT /backoffice/promo/ranks/config`, so changing one writes the ladder's settings row with it; a save that leaves a list or `periodicMinimumWager` out keeps the stored one, and only an explicit `null` clears the minimum. diff --git a/packages/core/src/promo/gamification/__tests__/eligible-products-backfill.int.test.ts b/packages/core/src/promo/gamification/__tests__/eligible-products-backfill.int.test.ts index e61ec0894..50d176830 100644 --- a/packages/core/src/promo/gamification/__tests__/eligible-products-backfill.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/eligible-products-backfill.int.test.ts @@ -53,14 +53,12 @@ beforeEach(async () => { }); async function asBeforeTheUpgrade() { - await db.drizzle.db.execute(sql`ALTER TABLE "promo_rank_config" - DROP CONSTRAINT "promo_rank_config_eligible_products_set", - DROP CONSTRAINT "promo_rank_config_rakeback_eligible_products_set", - DROP CONSTRAINT "promo_rank_config_rank_challenge_eligible_products_set", - ALTER COLUMN "eligible_products" SET DEFAULT '{}'`); - await db.drizzle.db.execute(sql`ALTER TABLE "promo_streak_config" - DROP CONSTRAINT "promo_streak_config_eligible_products_set", - ALTER COLUMN "eligible_products" SET DEFAULT '{}'`); + await db.drizzle.db.execute( + sql`ALTER TABLE "promo_rank_config" ALTER COLUMN "eligible_products" SET DEFAULT '{}'`, + ); + await db.drizzle.db.execute( + sql`ALTER TABLE "promo_streak_config" ALTER COLUMN "eligible_products" SET DEFAULT '{}'`, + ); } async function upgrade() { @@ -101,6 +99,18 @@ async function playerOnTier() { return userId; } +const settleSportsbookBet = (userId: string) => + db.drizzle.db.transaction((tx) => + rakeback.recordSettledWager(tx, { + userId, + currency: 'USDT', + realAmount: '100', + context: { provider: 'sportsbook', product: 'sportsbook' }, + round: { providerName: 'sportsbook:provider', externalRoundId: randomUUID() }, + qualifiedAt: new Date(), + }), + ); + describe('product lists on upgrade', () => { it('gives every empty list all products, so a sportsbook bet still earns rakeback', async () => { await asBeforeTheUpgrade(); @@ -113,16 +123,7 @@ describe('product lists on upgrade', () => { const userId = await playerOnTier(); await upgrade(); - await db.drizzle.db.transaction((tx) => - rakeback.recordSettledWager(tx, { - userId, - currency: 'USDT', - realAmount: '100', - context: { provider: 'sportsbook', product: 'sportsbook' }, - round: { providerName: 'sportsbook:provider', externalRoundId: randomUUID() }, - qualifiedAt: new Date(), - }), - ); + await settleSportsbookBet(userId); const [rank] = await db.drizzle.db.select().from(promoRankConfig); const [streak] = await db.drizzle.db.select().from(promoStreakConfig); @@ -156,32 +157,18 @@ describe('product lists on upgrade', () => { expect(streak?.eligibleProducts).toEqual(['live-casino']); }); - it('refuses an empty list once upgraded', async () => { - const violation = (constraint: string) => ({ - cause: expect.objectContaining({ code: '23514', constraint }), + it('still counts every product on a list the previous release saves empty after the upgrade', async () => { + await asBeforeTheUpgrade(); + await upgrade(); + await saveRankConfig({ + eligibleProducts: [], + rakebackEligibleProducts: [], + rankChallengeEligibleProducts: [], }); + const userId = await playerOnTier(); - await expect( - saveRankConfig({ - eligibleProducts: [], - rakebackEligibleProducts: ['casino'], - rankChallengeEligibleProducts: ['casino'], - }), - ).rejects.toMatchObject(violation('promo_rank_config_eligible_products_set')); - await expect(saveStreakConfig([])).rejects.toMatchObject( - violation('promo_streak_config_eligible_products_set'), - ); + await settleSportsbookBet(userId); - await saveRankConfig({ - eligibleProducts: ['casino'], - rakebackEligibleProducts: ['casino'], - rankChallengeEligibleProducts: ['casino'], - }); - await expect( - db.drizzle.db.update(promoRankConfig).set({ rakebackEligibleProducts: [] }), - ).rejects.toMatchObject(violation('promo_rank_config_rakeback_eligible_products_set')); - await expect( - db.drizzle.db.update(promoRankConfig).set({ rankChallengeEligibleProducts: [] }), - ).rejects.toMatchObject(violation('promo_rank_config_rank_challenge_eligible_products_set')); + expect(credit).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts b/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts index 0996b0762..c28a3b87b 100644 --- a/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts @@ -289,6 +289,16 @@ describe('which products earn rakeback', () => { expect(credit).not.toHaveBeenCalled(); }); + + it('counts every product the rank counts when the rakeback list is empty', async () => { + const userId = randomUUID(); + await givePlayerTier(userId, 'bronze'); + await db.drizzle.db.update(promoRankConfig).set({ rakebackEligibleProducts: [] }); + + await settledBet(rakeback, userId, 'bet-1'); + + expect(credit).toHaveBeenCalledTimes(1); + }); }); describe('rakeback on the house edge', () => { diff --git a/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts b/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts index a30ffb7a6..0b1855b0b 100644 --- a/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts @@ -238,6 +238,17 @@ describe('which products count toward the Rank Challenge', () => { expect(await wageredBy(account.id)).toBeUndefined(); }); + + it('counts every product when the list is empty', async () => { + const { account } = await seedPlayerWithUser(db); + await db.drizzle.db + .insert(promoRankConfig) + .values({ eligibleProducts: ['casino'], rankChallengeEligibleProducts: [] }); + + await wagerOn(account.id, 'sportsbook'); + + expect(await wageredBy(account.id)).toBe('1.000000000000000000'); + }); }); describe('settling a claim', () => { diff --git a/packages/core/src/promo/gamification/__tests__/rank-perk-products-backfill.int.test.ts b/packages/core/src/promo/gamification/__tests__/rank-perk-products-backfill.int.test.ts index 4ac8fc391..79d58be2f 100644 --- a/packages/core/src/promo/gamification/__tests__/rank-perk-products-backfill.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rank-perk-products-backfill.int.test.ts @@ -66,10 +66,7 @@ beforeEach(async () => { async function addProductLists() { await db.drizzle.db.execute(sql`ALTER TABLE "promo_rank_config" - DROP COLUMN "rakeback_eligible_products", DROP COLUMN "rank_challenge_eligible_products", - DROP CONSTRAINT "promo_rank_config_eligible_products_set"`); - await db.drizzle.db.execute(sql`ALTER TABLE "promo_streak_config" - DROP CONSTRAINT "promo_streak_config_eligible_products_set"`); + DROP COLUMN "rakeback_eligible_products", DROP COLUMN "rank_challenge_eligible_products"`); for (const statement of sqlOf(ADDED_IN)) { await db.drizzle.db.execute(sql.raw(statement)); } diff --git a/packages/core/src/promo/gamification/drizzle/migrations/0022_rank_streak_products_required.sql b/packages/core/src/promo/gamification/drizzle/migrations/0022_rank_streak_products_required.sql index 356322b26..84512a811 100644 --- a/packages/core/src/promo/gamification/drizzle/migrations/0022_rank_streak_products_required.sql +++ b/packages/core/src/promo/gamification/drizzle/migrations/0022_rank_streak_products_required.sql @@ -9,8 +9,4 @@ WHERE cardinality("rakeback_eligible_products") = 0;--> statement-breakpoint UPDATE "promo_rank_config" SET "rank_challenge_eligible_products" = '{"casino","live-casino","sportsbook","pvp"}' WHERE cardinality("rank_challenge_eligible_products") = 0;--> statement-breakpoint UPDATE "promo_streak_config" SET "eligible_products" = '{"casino","live-casino","sportsbook","pvp"}' -WHERE cardinality("eligible_products") = 0;--> statement-breakpoint -ALTER TABLE "promo_rank_config" ADD CONSTRAINT "promo_rank_config_eligible_products_set" CHECK (cardinality("promo_rank_config"."eligible_products") > 0);--> statement-breakpoint -ALTER TABLE "promo_rank_config" ADD CONSTRAINT "promo_rank_config_rakeback_eligible_products_set" CHECK (cardinality("promo_rank_config"."rakeback_eligible_products") > 0);--> statement-breakpoint -ALTER TABLE "promo_rank_config" ADD CONSTRAINT "promo_rank_config_rank_challenge_eligible_products_set" CHECK (cardinality("promo_rank_config"."rank_challenge_eligible_products") > 0);--> statement-breakpoint -ALTER TABLE "promo_streak_config" ADD CONSTRAINT "promo_streak_config_eligible_products_set" CHECK (cardinality("promo_streak_config"."eligible_products") > 0); \ No newline at end of file +WHERE cardinality("eligible_products") = 0; diff --git a/packages/core/src/promo/gamification/drizzle/migrations/meta/0022_snapshot.json b/packages/core/src/promo/gamification/drizzle/migrations/meta/0022_snapshot.json index dc16226b7..dd8ce6195 100644 --- a/packages/core/src/promo/gamification/drizzle/migrations/meta/0022_snapshot.json +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/0022_snapshot.json @@ -1565,20 +1565,7 @@ } }, "policies": {}, - "checkConstraints": { - "promo_rank_config_eligible_products_set": { - "name": "promo_rank_config_eligible_products_set", - "value": "cardinality(\"promo_rank_config\".\"eligible_products\") > 0" - }, - "promo_rank_config_rakeback_eligible_products_set": { - "name": "promo_rank_config_rakeback_eligible_products_set", - "value": "cardinality(\"promo_rank_config\".\"rakeback_eligible_products\") > 0" - }, - "promo_rank_config_rank_challenge_eligible_products_set": { - "name": "promo_rank_config_rank_challenge_eligible_products_set", - "value": "cardinality(\"promo_rank_config\".\"rank_challenge_eligible_products\") > 0" - } - }, + "checkConstraints": {}, "isRLSEnabled": false }, "public.promo_rank_level_up": { @@ -2013,12 +2000,7 @@ } }, "policies": {}, - "checkConstraints": { - "promo_streak_config_eligible_products_set": { - "name": "promo_streak_config_eligible_products_set", - "value": "cardinality(\"promo_streak_config\".\"eligible_products\") > 0" - } - }, + "checkConstraints": {}, "isRLSEnabled": false }, "public.promo_streak_daily_wager": { diff --git a/packages/core/src/promo/gamification/schema/index.ts b/packages/core/src/promo/gamification/schema/index.ts index 657f00972..7f232e9db 100644 --- a/packages/core/src/promo/gamification/schema/index.ts +++ b/packages/core/src/promo/gamification/schema/index.ts @@ -133,66 +133,52 @@ export type RankRewardTerms = NonNullable; * operator who has not decided what counts toward a rank has not launched ranks. The rank * challenge is not part of the ladder: absent, it counts `DEFAULT_RANK_PERK_PRODUCTS`. */ -export const promoRankConfig = pgTable( - 'promo_rank_config', - { - id: uuid().primaryKey().defaultRandom(), - // Unique, so the table can only ever hold the one row. - singletonKey: text().notNull().unique().default('global'), - /** Products whose stakes count toward a rank. */ - eligibleProducts: text().array().notNull(), - /** Products whose stakes earn rakeback. A stake must count toward the rank as well. */ - rakebackEligibleProducts: text() - .array() - .$type() - .notNull() - .default([...DEFAULT_RANK_PERK_PRODUCTS]), - /** Products whose stakes count toward the rank challenge. */ - rankChallengeEligibleProducts: text() - .array() - .$type() - .notNull() - .default([...DEFAULT_RANK_PERK_PRODUCTS]), - /** Terms each reward kind is granted under. A kind with no terms is not paid. */ - rewards: jsonb().$type().notNull().default({}), - /** What rewards are credited in, when that is not the ladder's own currency. */ - payoutCurrency: text(), - /** Credit a reward in the currency the player plays in, falling back to the two above. */ - payInPlayerCurrency: boolean().notNull().default(false), - /** Pay a periodic bonus only to players who wagered in the period it covers. */ - periodicRequiresActivity: boolean().notNull().default(true), - /** How much a player must wager inside the period to qualify. Null means any bet counts. */ - periodicMinimumWager: money(), - /** When each periodic payout closes, and so what window it pays for. All UTC. */ - payoutAnchors: jsonb() - .$type() - .notNull() - .default({ dailyHour: 0, weeklyDay: 1, monthlyDay: 1 }), - /** - * The end of the last period each kind was paid for. A payout runs only for a period that - * ends after its watermark, so moving an anchor cannot pay the same stretch of time twice and - * a late or repeated tick cannot reach back into a period already settled. - */ - paidThrough: jsonb().$type>>().notNull().default({}), - updatedBy: uuid(), - createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ withTimezone: true }) - .notNull() - .defaultNow() - .$onUpdate(() => new Date()), - }, - (t) => [ - check('promo_rank_config_eligible_products_set', sql`cardinality(${t.eligibleProducts}) > 0`), - check( - 'promo_rank_config_rakeback_eligible_products_set', - sql`cardinality(${t.rakebackEligibleProducts}) > 0`, - ), - check( - 'promo_rank_config_rank_challenge_eligible_products_set', - sql`cardinality(${t.rankChallengeEligibleProducts}) > 0`, - ), - ], -); +export const promoRankConfig = pgTable('promo_rank_config', { + id: uuid().primaryKey().defaultRandom(), + // Unique, so the table can only ever hold the one row. + singletonKey: text().notNull().unique().default('global'), + /** Products whose stakes count toward a rank. Empty counts every product. */ + eligibleProducts: text().array().notNull(), + /** Products whose stakes earn rakeback. A stake must count toward the rank as well. Empty counts every product. */ + rakebackEligibleProducts: text() + .array() + .$type() + .notNull() + .default([...DEFAULT_RANK_PERK_PRODUCTS]), + /** Products whose stakes count toward the rank challenge. Empty counts every product. */ + rankChallengeEligibleProducts: text() + .array() + .$type() + .notNull() + .default([...DEFAULT_RANK_PERK_PRODUCTS]), + /** Terms each reward kind is granted under. A kind with no terms is not paid. */ + rewards: jsonb().$type().notNull().default({}), + /** What rewards are credited in, when that is not the ladder's own currency. */ + payoutCurrency: text(), + /** Credit a reward in the currency the player plays in, falling back to the two above. */ + payInPlayerCurrency: boolean().notNull().default(false), + /** Pay a periodic bonus only to players who wagered in the period it covers. */ + periodicRequiresActivity: boolean().notNull().default(true), + /** How much a player must wager inside the period to qualify. Null means any bet counts. */ + periodicMinimumWager: money(), + /** When each periodic payout closes, and so what window it pays for. All UTC. */ + payoutAnchors: jsonb() + .$type() + .notNull() + .default({ dailyHour: 0, weeklyDay: 1, monthlyDay: 1 }), + /** + * The end of the last period each kind was paid for. A payout runs only for a period that + * ends after its watermark, so moving an anchor cannot pay the same stretch of time twice and + * a late or repeated tick cannot reach back into a period already settled. + */ + paidThrough: jsonb().$type>>().notNull().default({}), + updatedBy: uuid(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); export type PromoRankConfig = typeof promoRankConfig.$inferSelect; @@ -273,28 +259,22 @@ export type PromoRankLevelUp = typeof promoRankLevelUp.$inferSelect; * day counts and no milestone pays: an operator who has not decided what qualifies has not * launched the streak. */ -export const promoStreakConfig = pgTable( - 'promo_streak_config', - { - id: uuid().primaryKey().defaultRandom(), - singletonKey: text().notNull().unique().default('global'), - currency: text().notNull(), - dailyMinWager: money().notNull(), - /** Products whose stakes count toward the streak. */ - eligibleProducts: text().array().notNull(), - milestones: jsonb().$type().notNull().default([]), - resetAfterDay: integer().notNull().default(30), - updatedBy: uuid(), - createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ withTimezone: true }) - .notNull() - .defaultNow() - .$onUpdate(() => new Date()), - }, - (t) => [ - check('promo_streak_config_eligible_products_set', sql`cardinality(${t.eligibleProducts}) > 0`), - ], -); +export const promoStreakConfig = pgTable('promo_streak_config', { + id: uuid().primaryKey().defaultRandom(), + singletonKey: text().notNull().unique().default('global'), + currency: text().notNull(), + dailyMinWager: money().notNull(), + /** Products whose stakes count toward the streak. Empty counts every product. */ + eligibleProducts: text().array().notNull(), + milestones: jsonb().$type().notNull().default([]), + resetAfterDay: integer().notNull().default(30), + updatedBy: uuid(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); export type PromoStreakConfig = typeof promoStreakConfig.$inferSelect; diff --git a/packages/core/src/promo/gamification/service/rakeback.service.ts b/packages/core/src/promo/gamification/service/rakeback.service.ts index cba48de71..fff2d59ed 100644 --- a/packages/core/src/promo/gamification/service/rakeback.service.ts +++ b/packages/core/src/promo/gamification/service/rakeback.service.ts @@ -47,7 +47,7 @@ export type RakebackSettings = { basis: RakebackBasis; payout: RakebackPayout }; const DEFAULT_SETTINGS: RakebackSettings = { basis: 'stake', payout: 'instant' }; const countsToward = (eligibleProducts: readonly string[], product: string) => - eligibleProducts.includes(product); + eligibleProducts.length === 0 || eligibleProducts.includes(product); /** The part of a bet's own-money stake rakeback is a share of; zero when it cannot be priced. */ export function rakebackBase(realAmount: string, basis: RakebackBasis, rtp?: string): string { diff --git a/packages/core/src/promo/gamification/service/rank-challenge.service.ts b/packages/core/src/promo/gamification/service/rank-challenge.service.ts index 01657cd0d..5fce3a500 100644 --- a/packages/core/src/promo/gamification/service/rank-challenge.service.ts +++ b/packages/core/src/promo/gamification/service/rank-challenge.service.ts @@ -55,7 +55,7 @@ export class RankChallengeService implements WagerTrackingCommands { const products = promoRankConfig.rankChallengeEligibleProducts; const product = args.context.product; const challengeCountsProduct = sql`coalesce( - (select ${product} = any(${products}) + (select cardinality(${products}) = 0 or ${product} = any(${products}) from ${promoRankConfig} where ${promoRankConfig.singletonKey} = 'global'), ${DEFAULT_RANK_PERK_PRODUCTS.some((p) => p === product)}::boolean )`; diff --git a/packages/core/src/promo/gamification/service/rank.service.ts b/packages/core/src/promo/gamification/service/rank.service.ts index 30ccd6e19..61ef44b17 100644 --- a/packages/core/src/promo/gamification/service/rank.service.ts +++ b/packages/core/src/promo/gamification/service/rank.service.ts @@ -37,8 +37,9 @@ type LadderRung = Pick; const tierFor = (ladder: readonly T[], lifetime: string) => ladder.filter((tier) => moneyCompare(tier.wagerThreshold, lifetime) <= 0).at(-1); +// An empty list counts every bet. A bet always names its product, so there is no third case. const countsToward = (eligibleProducts: readonly string[], product: string) => - eligibleProducts.includes(product); + eligibleProducts.length === 0 || eligibleProducts.includes(product); export class RankService implements WagerTrackingCommands { constructor( diff --git a/packages/core/src/promo/gamification/service/streak.service.ts b/packages/core/src/promo/gamification/service/streak.service.ts index c1b39a98e..393dd7fc9 100644 --- a/packages/core/src/promo/gamification/service/streak.service.ts +++ b/packages/core/src/promo/gamification/service/streak.service.ts @@ -27,8 +27,9 @@ import { roundReversal } from '../shared/round-reversal.js'; export const StreakConfigNotSetError = makeNotFoundError('StreakConfig'); +// An empty list counts every bet - the same convention `RankService` uses for eligibleProducts. const countsToward = (eligibleProducts: readonly string[], product: string) => - eligibleProducts.includes(product); + eligibleProducts.length === 0 || eligibleProducts.includes(product); const isoDate = (date: Date) => date.toISOString().slice(0, 10); const DAY_MS = 86_400_000;