feat: add giveaway system

Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
This commit is contained in:
Ahmad 2025-04-13 16:13:14 -04:00
parent e898a9238d
commit d9d5f087e7
No known key found for this signature in database
GPG key ID: 8FD8A93530D182BF
23 changed files with 2811 additions and 168 deletions

View file

@ -4,14 +4,15 @@ import { Client, Collection, GuildMember } from 'discord.js';
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import * as schema from './schema.js';
import { loadConfig } from '../util/configLoader.js';
import { loadConfig } from '@/util/configLoader.js';
import { del, exists, getJson, setJson } from './redis.js';
import { calculateLevelFromXp } from '../util/levelingSystem.js';
import { calculateLevelFromXp } from '@/util/levelingSystem.js';
import { selectGiveawayWinners } from '@/util/giveaways/giveawayManager.js';
import {
logManagerNotification,
NotificationType,
notifyManagers,
} from '../util/notificationHandler.js';
} from '@/util/notificationHandler.js';
const { Pool } = pkg;
const config = loadConfig();
@ -192,7 +193,7 @@ export async function ensureDatabaseConnection(): Promise<boolean> {
* @param error - Original error object
*/
export const handleDbError = (errorMessage: string, error: Error): never => {
console.error(`${errorMessage}: `, error);
console.error(`${errorMessage}:`, error);
if (
error.message.includes('connection') ||
@ -448,6 +449,7 @@ export async function getUserLevel(
xp: 0,
level: 0,
lastMessageTimestamp: new Date(),
messagesSent: 0,
};
await db.insert(schema.levelTable).values(newLevel);
@ -470,6 +472,7 @@ export async function addXpToUser(
leveledUp: boolean;
newLevel: number;
oldLevel: number;
messagesSent: number;
}> {
try {
await ensureDbInitialized();
@ -485,6 +488,7 @@ export async function addXpToUser(
userData.xp += amount;
userData.lastMessageTimestamp = new Date();
userData.level = calculateLevelFromXp(userData.xp);
userData.messagesSent += 1;
await invalidateLeaderboardCache();
await invalidateCache(cacheKey);
@ -497,6 +501,7 @@ export async function addXpToUser(
xp: userData.xp,
level: userData.level,
lastMessageTimestamp: userData.lastMessageTimestamp,
messagesSent: userData.messagesSent,
})
.where(eq(schema.levelTable.discordId, discordId))
.returning();
@ -510,6 +515,7 @@ export async function addXpToUser(
leveledUp: userData.level > currentLevel,
newLevel: userData.level,
oldLevel: currentLevel,
messagesSent: userData.messagesSent,
};
} catch (error) {
return handleDbError('Error adding XP to user', error as Error);
@ -551,7 +557,7 @@ export async function getUserRank(discordId: string): Promise<number> {
* Clear leaderboard cache
*/
export async function invalidateLeaderboardCache(): Promise<void> {
await invalidateCache('xp-leaderboard-cache');
await invalidateCache('xp-leaderboard');
}
/**
@ -571,7 +577,7 @@ async function getLeaderboardData(): Promise<
console.error('Database not initialized, cannot get leaderboard data');
}
const cacheKey = 'xp-leaderboard-cache';
const cacheKey = 'xp-leaderboard';
return withCache<Array<{ discordId: string; xp: number }>>(
cacheKey,
async () => {
@ -911,3 +917,277 @@ export async function deleteFact(id: number): Promise<void> {
return handleDbError('Failed to delete fact', error as Error);
}
}
// ========================
// Giveaway Functions
// ========================
/**
* Create a giveaway in the database
* @param giveawayData - Data for the giveaway
* @returns Created giveaway object
*/
export async function createGiveaway(giveawayData: {
channelId: string;
messageId: string;
endAt: Date;
prize: string;
winnerCount: number;
hostId: string;
requirements?: {
level?: number;
roleId?: string;
messageCount?: number;
requireAll?: boolean;
};
bonuses?: {
roles?: Array<{ id: string; entries: number }>;
levels?: Array<{ threshold: number; entries: number }>;
messages?: Array<{ threshold: number; entries: number }>;
};
}): Promise<schema.giveawayTableTypes> {
try {
await ensureDbInitialized();
if (!db) {
console.error('Database not initialized, cannot create giveaway');
}
const [giveaway] = await db
.insert(schema.giveawayTable)
.values({
channelId: giveawayData.channelId,
messageId: giveawayData.messageId,
endAt: giveawayData.endAt,
prize: giveawayData.prize,
winnerCount: giveawayData.winnerCount,
hostId: giveawayData.hostId,
requiredLevel: giveawayData.requirements?.level,
requiredRoleId: giveawayData.requirements?.roleId,
requiredMessageCount: giveawayData.requirements?.messageCount,
requireAllCriteria: giveawayData.requirements?.requireAll ?? true,
bonusEntries:
giveawayData.bonuses as schema.giveawayTableTypes['bonusEntries'],
})
.returning();
return giveaway as schema.giveawayTableTypes;
} catch (error) {
return handleDbError('Failed to create giveaway', error as Error);
}
}
/**
* Get a giveaway by ID or message ID
* @param id - ID of the giveaway
* @param isDbId - Whether the ID is a database ID
* @returns Giveaway object or undefined if not found
*/
export async function getGiveaway(
id: string | number,
isDbId = false,
): Promise<schema.giveawayTableTypes | undefined> {
try {
await ensureDbInitialized();
if (!db) {
console.error('Database not initialized, cannot get giveaway');
return undefined;
}
if (isDbId) {
const numId = typeof id === 'string' ? parseInt(id) : id;
const [giveaway] = await db
.select()
.from(schema.giveawayTable)
.where(eq(schema.giveawayTable.id, numId))
.limit(1);
return giveaway as schema.giveawayTableTypes;
} else {
const [giveaway] = await db
.select()
.from(schema.giveawayTable)
.where(eq(schema.giveawayTable.messageId, id as string))
.limit(1);
return giveaway as schema.giveawayTableTypes;
}
} catch (error) {
return handleDbError('Failed to get giveaway', error as Error);
}
}
/**
* Get all active giveaways
* @returns Array of active giveaway objects
*/
export async function getActiveGiveaways(): Promise<
schema.giveawayTableTypes[]
> {
try {
await ensureDbInitialized();
if (!db) {
console.error('Database not initialized, cannot get active giveaways');
}
return (await db
.select()
.from(schema.giveawayTable)
.where(
eq(schema.giveawayTable.status, 'active'),
)) as schema.giveawayTableTypes[];
} catch (error) {
return handleDbError('Failed to get active giveaways', error as Error);
}
}
/**
* Update giveaway participants
* @param messageId - ID of the giveaway message
* @param userId - ID of the user to add
* @param entries - Number of entries to add
* @return 'success' | 'already_entered' | 'inactive' | 'error'
*/
export async function addGiveawayParticipant(
messageId: string,
userId: string,
entries = 1,
): Promise<'success' | 'already_entered' | 'inactive' | 'error'> {
try {
await ensureDbInitialized();
if (!db) {
console.error('Database not initialized, cannot add participant');
return 'error';
}
const giveaway = await getGiveaway(messageId);
if (!giveaway || giveaway.status !== 'active') {
return 'inactive';
}
if (giveaway.participants?.includes(userId)) {
return 'already_entered';
}
const participants = [...(giveaway.participants || [])];
for (let i = 0; i < entries; i++) {
participants.push(userId);
}
await db
.update(schema.giveawayTable)
.set({ participants: participants })
.where(eq(schema.giveawayTable.messageId, messageId));
return 'success';
} catch (error) {
handleDbError('Failed to add giveaway participant', error as Error);
return 'error';
}
}
/**
* End a giveaway
* @param id - ID of the giveaway
* @param isDbId - Whether the ID is a database ID
* @param forceWinners - Array of user IDs to force as winners
* @return Updated giveaway object
*/
export async function endGiveaway(
id: string | number,
isDbId = false,
forceWinners?: string[],
): Promise<schema.giveawayTableTypes | undefined> {
try {
await ensureDbInitialized();
if (!db) {
console.error('Database not initialized, cannot end giveaway');
return undefined;
}
const giveaway = await getGiveaway(id, isDbId);
if (!giveaway || giveaway.status !== 'active' || !giveaway.participants) {
return undefined;
}
const winners = selectGiveawayWinners(
giveaway.participants,
giveaway.winnerCount,
forceWinners,
);
const [updatedGiveaway] = await db
.update(schema.giveawayTable)
.set({
status: 'ended',
winnersIds: winners,
})
.where(eq(schema.giveawayTable.id, giveaway.id))
.returning();
return updatedGiveaway as schema.giveawayTableTypes;
} catch (error) {
return handleDbError('Failed to end giveaway', error as Error);
}
}
/**
* Reroll winners for a giveaway
* @param id - ID of the giveaway
* @return Updated giveaway object
*/
export async function rerollGiveaway(
id: string,
): Promise<schema.giveawayTableTypes | undefined> {
try {
await ensureDbInitialized();
if (!db) {
console.error('Database not initialized, cannot reroll giveaway');
return undefined;
}
const giveaway = await getGiveaway(id, true);
if (
!giveaway ||
!giveaway.participants ||
giveaway.participants.length === 0 ||
giveaway.status !== 'ended'
) {
console.warn(
`Cannot reroll giveaway ${id}: Not found, no participants, or not ended.`,
);
return undefined;
}
const newWinners = selectGiveawayWinners(
giveaway.participants,
giveaway.winnerCount,
undefined,
giveaway.winnersIds ?? [],
);
if (newWinners.length === 0) {
console.warn(
`Cannot reroll giveaway ${id}: No eligible participants left after excluding previous winners.`,
);
return giveaway;
}
const [updatedGiveaway] = await db
.update(schema.giveawayTable)
.set({
winnersIds: newWinners,
})
.where(eq(schema.giveawayTable.id, giveaway.id))
.returning();
return updatedGiveaway as schema.giveawayTableTypes;
} catch (error) {
return handleDbError('Failed to reroll giveaway', error as Error);
}
}

View file

@ -1,11 +1,12 @@
import {
boolean,
integer,
jsonb,
pgTable,
timestamp,
varchar,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { InferSelectModel, relations } from 'drizzle-orm';
export interface memberTableTypes {
id?: number;
@ -30,6 +31,7 @@ export interface levelTableTypes {
discordId: string;
xp: number;
level: number;
messagesSent: number;
lastMessageTimestamp?: Date;
}
@ -40,6 +42,7 @@ export const levelTable = pgTable('levels', {
.references(() => memberTable.discordId, { onDelete: 'cascade' }),
xp: integer('xp').notNull().default(0),
level: integer('level').notNull().default(0),
messagesSent: integer('messages_sent').notNull().default(0),
lastMessageTimestamp: timestamp('last_message_timestamp'),
});
@ -111,3 +114,32 @@ export const factTable = pgTable('facts', {
approved: boolean('approved').default(false).notNull(),
usedOn: timestamp('used_on'),
});
export type giveawayTableTypes = InferSelectModel<typeof giveawayTable> & {
bonusEntries: {
roles?: Array<{ id: string; entries: number }>;
levels?: Array<{ threshold: number; entries: number }>;
messages?: Array<{ threshold: number; entries: number }>;
};
};
export const giveawayTable = pgTable('giveaways', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
channelId: varchar('channel_id').notNull(),
messageId: varchar('message_id').notNull().unique(),
createdAt: timestamp('created_at').defaultNow(),
endAt: timestamp('end_at').notNull(),
prize: varchar('prize').notNull(),
winnerCount: integer('winner_count').notNull().default(1),
hostId: varchar('host_id')
.references(() => memberTable.discordId)
.notNull(),
status: varchar('status').notNull().default('active'),
participants: varchar('participants').array().default([]),
winnersIds: varchar('winners_ids').array().default([]),
requiredLevel: integer('required_level'),
requiredRoleId: varchar('required_role_id'),
requiredMessageCount: integer('required_message_count'),
requireAllCriteria: boolean('require_all_criteria').default(true),
bonusEntries: jsonb('bonus_entries').default({}),
});