From c4df35410d01307ae206d6dc9012dc00f9143179 Mon Sep 17 00:00:00 2001 From: Miguel Campos Date: Sat, 10 Aug 2024 23:10:38 -0700 Subject: [PATCH] raffles: implement consolation prize system --- .../pages/api/cron/find-expired-raffles.ts | 49 +++++++++- .../notifications/src/destinations.ts | 2 +- libs/services/notifications/src/index.ts | 3 + libs/services/push/src/index.ts | 16 ++++ libs/services/raffles/src/index.ts | 96 +++++++------------ .../templates/src/lib/push-templates.ts | 26 +++-- libs/services/templates/src/lib/types.ts | 8 +- 7 files changed, 123 insertions(+), 77 deletions(-) diff --git a/apps/charity/pages/api/cron/find-expired-raffles.ts b/apps/charity/pages/api/cron/find-expired-raffles.ts index 6396b3b3..8f4ce6cc 100644 --- a/apps/charity/pages/api/cron/find-expired-raffles.ts +++ b/apps/charity/pages/api/cron/find-expired-raffles.ts @@ -1,8 +1,51 @@ import { prisma } from '@worksheets/prisma'; -import { RafflesService } from '@worksheets/services/raffles'; +import { NotificationsService } from '@worksheets/services/notifications'; +import { + EXPIRED_RAFFLE_PROPS, + RafflesService, +} from '@worksheets/services/raffles'; import { createCronJob } from '@worksheets/util/cron'; +import { retryTransaction } from '@worksheets/util/prisma'; export default createCronJob(async () => { - const raffles = new RafflesService(prisma); - await raffles.processExpiredRaffles(); + const notifications = new NotificationsService(prisma); + const expiredRaffles = await prisma.raffle.findMany({ + where: { + expiresAt: { + lte: new Date(), + }, + status: 'ACTIVE', + }, + select: EXPIRED_RAFFLE_PROPS, + }); + console.info(`Found ${expiredRaffles.length} expired raffles.`); + + for (const expired of expiredRaffles) { + console.info(`Processing expired raffle ${expired.id}.`); + const { winners, losers, raffle } = await retryTransaction( + prisma, + async (tx) => { + const raffles = new RafflesService(tx); + return await raffles.processExpiredRaffle(expired); + } + ); + console.info(`Processed expired raffle ${expired.id}.`); + for (const winner of winners) { + await notifications.send('won-raffle', { + raffle, + user: winner.user, + item: raffle.item, + }); + } + await notifications.send('lost-raffle', { + ...raffle, + participants: losers, + }); + await notifications.send('raffle-expired', { + ...raffle, + name: raffle.name ?? raffle.item.name, + }); + } + + console.info(`Finished processing ${expiredRaffles.length} expired raffles.`); }); diff --git a/libs/services/notifications/src/destinations.ts b/libs/services/notifications/src/destinations.ts index 9c311ff4..633bef16 100644 --- a/libs/services/notifications/src/destinations.ts +++ b/libs/services/notifications/src/destinations.ts @@ -30,7 +30,7 @@ const wonRaffleTemplates: TemplateBuilder<'won-raffle'> = (payload) => ({ }); const lostRaffleTemplates: TemplateBuilder<'lost-raffle'> = (payload) => ({ - push: PushTemplates.lostRaffle(payload), + pushMany: PushTemplates.lostRaffle(payload), }); const expiringItemReminderTemplates: TemplateBuilder< diff --git a/libs/services/notifications/src/index.ts b/libs/services/notifications/src/index.ts index 110e4cf4..a3039b38 100644 --- a/libs/services/notifications/src/index.ts +++ b/libs/services/notifications/src/index.ts @@ -43,6 +43,9 @@ export class NotificationsService { if (targets.push) { tasks.push(this.#push.notify(targets.push)); } + if (targets.pushMany) { + tasks.push(this.#push.notifyMany(targets.pushMany)); + } if (targets.broadcast) { tasks.push(this.#push.notify(targets.broadcast)); } diff --git a/libs/services/push/src/index.ts b/libs/services/push/src/index.ts index edd90bad..d4ee211a 100644 --- a/libs/services/push/src/index.ts +++ b/libs/services/push/src/index.ts @@ -39,6 +39,22 @@ export class PushService { userId: user.id, })), }); + + return 'okay'; + } + + async notifyMany(notifications: PushNotifyInput[]) { + const filtered = notifications.filter((n) => n.userIds?.length); + await this.#db.notification.createMany({ + data: filtered.flatMap( + (n) => + n.userIds?.map((userId) => ({ + type: n.type, + text: n.text, + userId, + })) ?? [] + ), + }); return 'okay'; } diff --git a/libs/services/raffles/src/index.ts b/libs/services/raffles/src/index.ts index 49600c8a..9a69538f 100644 --- a/libs/services/raffles/src/index.ts +++ b/libs/services/raffles/src/index.ts @@ -2,20 +2,19 @@ import { TRPCError } from '@trpc/server'; import { ItemId } from '@worksheets/data/items'; import { PrismaClient, PrismaTransactionalClient } from '@worksheets/prisma'; import { InventoryService } from '@worksheets/services/inventory'; -import { NotificationsService } from '@worksheets/services/notifications'; import { RAFFLE_ENTRY_FEE } from '@worksheets/util/settings'; -import { EXPIRED_RAFFLE_PROPS, ExpiredRaffle } from './types'; +import { ExpiredRaffle } from './types'; import { pickWinners } from './util/winners'; +export * from './types'; + export class RafflesService { #db: PrismaClient | PrismaTransactionalClient; #inventory: InventoryService; - #notifications: NotificationsService; constructor(db: PrismaClient | PrismaTransactionalClient) { this.#db = db; this.#inventory = new InventoryService(db); - this.#notifications = new NotificationsService(db); } async addEntries({ @@ -108,35 +107,7 @@ export class RafflesService { } } - async processExpiredRaffles() { - const expiredRaffles = await this.#db.raffle.findMany({ - where: { - expiresAt: { - lte: new Date(), - }, - status: 'ACTIVE', - }, - select: EXPIRED_RAFFLE_PROPS, - }); - - console.info(`Found ${expiredRaffles.length} expired raffles.`); - for (const expired of expiredRaffles) { - await this.#processExpiredRaffle(expired); - } - console.info( - `Finished processing ${expiredRaffles.length} expired raffles.` - ); - } - - async #processExpiredRaffle(raffle: ExpiredRaffle) { - try { - await this.#assignWinners(raffle); - } catch (error) { - console.error(`Failed to process raffle ${raffle.id}`, error); - } - } - - async #assignWinners(raffle: ExpiredRaffle) { + async processExpiredRaffle(raffle: ExpiredRaffle) { if (raffle.participants.length === 0) { console.info('No participants in raffle', raffle.id); await this.#db.raffle.update({ @@ -147,7 +118,11 @@ export class RafflesService { status: 'COMPLETE', }, }); - return; + return { + raffle, + losers: [], + winners: [], + }; } if (!raffle.participants.every((p) => p.numEntries >= 0)) { @@ -158,13 +133,18 @@ export class RafflesService { }); } + await this.#db.raffle.update({ + where: { + id: raffle.id, + }, + data: { + status: 'COMPLETE', + }, + }); + const winners = pickWinners(raffle.numWinners, raffle.participants); - console.info( - 'Winners:', - winners.map((w) => w.user.email).join(', '), - raffle - ); + console.info('Winners:', winners.map((w) => w.user.email).join(', ')); for (const winner of winners) { await this.#db.raffleParticipation.update({ @@ -181,31 +161,21 @@ export class RafflesService { raffle.item.id as ItemId, 1 ); - await this.#notifications.send('won-raffle', { - user: winner.user, - item: raffle.item, - }); } - await this.#db.raffle.update({ - where: { - id: raffle.id, - }, - data: { - status: 'COMPLETE', - }, - }); - // notify losers - await this.#notifications.send('lost-raffle', { - ...raffle, - // do not notify winners - participants: raffle.participants.filter( - (p) => !winners.some((w) => w.participationId === p.id) - ), - }); - await this.#notifications.send('raffle-expired', { - ...raffle, - name: raffle.name ?? raffle.item.name, - }); + const losers = raffle.participants.filter( + (p) => !winners.some((w) => w.participationId === p.id) + ); + + console.info('Losers:', losers.map((w) => w.user.email).join(', ')); + + for (const loser of losers) { + await this.#inventory.increment(loser.user.id, '1', loser.numEntries); + } + return { + raffle, + losers, + winners, + }; } } diff --git a/libs/services/templates/src/lib/push-templates.ts b/libs/services/templates/src/lib/push-templates.ts index 7d92a12e..a4e7c688 100644 --- a/libs/services/templates/src/lib/push-templates.ts +++ b/libs/services/templates/src/lib/push-templates.ts @@ -135,16 +135,18 @@ export class PushTemplates { static lostRaffle( opts: ExtractTemplatePayload<'lost-raffle'> - ): PushNotifyInput { - return { + ): PushNotifyInput[] { + return opts.participants.map((p) => ({ type: 'RAFFLE', - text: `${ - opts.item.name - } giveaway has ended! Giveaway #${ opts.id - )}">View results.`, - userIds: opts.participants.map((p) => p.user.id), - }; + } for a ${ + opts.item.name + } has ended! You have been awarded a consolation prize of ${ + p.numEntries + } ${pluralize('token', p.numEntries)}.`, + userIds: [p.user.id], + })); } static wonRaffle( @@ -152,7 +154,13 @@ export class PushTemplates { ): PushNotifyInput { return { type: 'RAFFLE', - text: `You won a ${opts.item.name} in a raffle! See your inventory.`, + text: `Congratulations! You are the winner of giveaway #${opts.raffle.id} for a ${ + opts.item.name + }. Visit your your inventory.`, userIds: [opts.user.id], }; } diff --git a/libs/services/templates/src/lib/types.ts b/libs/services/templates/src/lib/types.ts index 8665f4b8..e94f2d3b 100644 --- a/libs/services/templates/src/lib/types.ts +++ b/libs/services/templates/src/lib/types.ts @@ -34,11 +34,15 @@ export type NotificationTemplate = | { type: 'won-raffle'; payload: { + raffle: { + id: number; + }; user: { id: string; email: string; }; item: { + id: string; name: string; expiration: number | null; }; @@ -94,8 +98,9 @@ export type NotificationTemplate = user: { id: string; }; + numEntries: number; }[]; - item: { name: string }; + item: { name: string; id: string }; }; } | { @@ -328,6 +333,7 @@ export type TemplateBuilder = ( newsletter?: ScheduleNewsletterInput[]; discord?: DiscordMessageInput; push?: PushNotifyInput; + pushMany?: PushNotifyInput[]; email?: SendEmailInput; broadcast?: PushNotifyInput; };