Skip to content

Commit

Permalink
raffles: implement consolation prize system
Browse files Browse the repository at this point in the history
  • Loading branch information
GGonryun committed Aug 11, 2024
1 parent 1821f36 commit c4df354
Show file tree
Hide file tree
Showing 7 changed files with 123 additions and 77 deletions.
49 changes: 46 additions & 3 deletions apps/charity/pages/api/cron/find-expired-raffles.ts
Original file line number Diff line number Diff line change
@@ -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.`);
});
2 changes: 1 addition & 1 deletion libs/services/notifications/src/destinations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
3 changes: 3 additions & 0 deletions libs/services/notifications/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
16 changes: 16 additions & 0 deletions libs/services/push/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
96 changes: 33 additions & 63 deletions libs/services/raffles/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand All @@ -147,7 +118,11 @@ export class RafflesService {
status: 'COMPLETE',
},
});
return;
return {
raffle,
losers: [],
winners: [],
};
}

if (!raffle.participants.every((p) => p.numEntries >= 0)) {
Expand All @@ -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({
Expand All @@ -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,
};
}
}
26 changes: 17 additions & 9 deletions libs/services/templates/src/lib/push-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,24 +135,32 @@ export class PushTemplates {

static lostRaffle(
opts: ExtractTemplatePayload<'lost-raffle'>
): PushNotifyInput {
return {
): PushNotifyInput[] {
return opts.participants.map((p) => ({
type: 'RAFFLE',
text: `<a href="${RAFFLE_URL(opts.id)}">${
opts.item.name
}</a> giveaway has ended! <a href="${RAFFLE_URL(
text: `<a href="${RAFFLE_URL(opts.id)}">Giveaway #${
opts.id
)}">View results</a>.`,
userIds: opts.participants.map((p) => p.user.id),
};
}</a> for a <a href="${ITEM_URL(opts.item.id)}">${
opts.item.name
}</a> has ended! You have been awarded a consolation prize of ${
p.numEntries
} ${pluralize('token', p.numEntries)}.`,
userIds: [p.user.id],
}));
}

static wonRaffle(
opts: ExtractTemplatePayload<'won-raffle'>
): PushNotifyInput {
return {
type: 'RAFFLE',
text: `You won a ${opts.item.name} in a raffle! <a href="${ACCOUNT_INVENTORY_URL}">See your inventory</a>.`,
text: `Congratulations! You are the winner of <a href="${RAFFLE_URL(
opts.raffle.id
)}">giveaway #${opts.raffle.id}</a> for a <a href="${ITEM_URL(
opts.item.id
)}">${
opts.item.name
}</a>. Visit your <a href="${ACCOUNT_INVENTORY_URL}">your inventory</a>.`,
userIds: [opts.user.id],
};
}
Expand Down
8 changes: 7 additions & 1 deletion libs/services/templates/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -94,8 +98,9 @@ export type NotificationTemplate =
user: {
id: string;
};
numEntries: number;
}[];
item: { name: string };
item: { name: string; id: string };
};
}
| {
Expand Down Expand Up @@ -328,6 +333,7 @@ export type TemplateBuilder<T extends NotificationTemplateType = any> = (
newsletter?: ScheduleNewsletterInput[];
discord?: DiscordMessageInput;
push?: PushNotifyInput;
pushMany?: PushNotifyInput[];
email?: SendEmailInput;
broadcast?: PushNotifyInput;
};

0 comments on commit c4df354

Please sign in to comment.