diff --git a/docs/modules/gamification.md b/docs/modules/gamification.md index 1ca75395d..609fd044c 100644 --- a/docs/modules/gamification.md +++ b/docs/modules/gamification.md @@ -40,14 +40,20 @@ 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. 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 new file mode 100644 index 000000000..50d176830 --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/eligible-products-backfill.int.test.ts @@ -0,0 +1,174 @@ +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" 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() { + 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; +} + +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(); + await saveRankConfig({ + eligibleProducts: [], + rakebackEligibleProducts: [], + rankChallengeEligibleProducts: [], + }); + await saveStreakConfig([]); + const userId = await playerOnTier(); + + await upgrade(); + await settleSportsbookBet(userId); + + 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('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 settleSportsbookBet(userId); + + 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 70c9c8ea9..c28a3b87b 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, 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..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 @@ -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,7 +230,9 @@ 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'); @@ -238,7 +241,9 @@ describe('which products count toward the Rank Challenge', () => { it('counts every product when the list is empty', async () => { const { account } = await seedPlayerWithUser(db); - await db.drizzle.db.insert(promoRankConfig).values({ rankChallengeEligibleProducts: [] }); + await db.drizzle.db + .insert(promoRankConfig) + .values({ eligibleProducts: ['casino'], rankChallengeEligibleProducts: [] }); await wagerOn(account.id, 'sportsbook'); 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..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 @@ -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,16 @@ 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) { + 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 +81,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 +105,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..84512a811 --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/0022_rank_streak_products_required.sql @@ -0,0 +1,12 @@ +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; 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..dd8ce6195 --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/0022_snapshot.json @@ -0,0 +1,2335 @@ +{ + "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": {}, + "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": {}, + "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..7f232e9db 100644 --- a/packages/core/src/promo/gamification/schema/index.ts +++ b/packages/core/src/promo/gamification/schema/index.ts @@ -138,7 +138,7 @@ export const promoRankConfig = pgTable('promo_rank_config', { // 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([]), + 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() @@ -265,7 +265,7 @@ export const promoStreakConfig = pgTable('promo_streak_config', { currency: text().notNull(), dailyMinWager: money().notNull(), /** Products whose stakes count toward the streak. Empty counts every product. */ - eligibleProducts: text().array().notNull().default([]), + eligibleProducts: text().array().notNull(), milestones: jsonb().$type().notNull().default([]), resetAfterDay: integer().notNull().default(30), updatedBy: uuid(), 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/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: {