diff --git a/src/commands/fun/fact.ts b/src/commands/fun/fact.ts index 4848932..45869e0 100644 --- a/src/commands/fun/fact.ts +++ b/src/commands/fun/fact.ts @@ -17,6 +17,7 @@ import { import { postFactOfTheDay } from '@/util/factManager.js'; import { loadConfig } from '@/util/configLoader.js'; import { SubcommandCommand } from '@/types/CommandTypes.js'; +import { createPaginationButtons } from '@/util/helpers.js'; const command: SubcommandCommand = { data: new SlashCommandBuilder() @@ -197,6 +198,7 @@ const command: SubcommandCommand = { return; } + const FACTS_PER_PAGE = 5; const pendingFacts = await getPendingFacts(); if (pendingFacts.length === 0) { @@ -206,20 +208,85 @@ const command: SubcommandCommand = { return; } - const embed = new EmbedBuilder() - .setTitle('Pending Facts') - .setColor(0x0099ff) - .setDescription( - pendingFacts - .map((fact) => { - return `**ID #${fact.id}**\n${fact.content}\nSubmitted by: <@${fact.addedBy}>\nSource: ${fact.source || 'Not provided'}`; - }) - .join('\n\n'), - ) - .setTimestamp(); + const pages: EmbedBuilder[] = []; + for (let i = 0; i < pendingFacts.length; i += FACTS_PER_PAGE) { + const pageFacts = pendingFacts.slice(i, i + FACTS_PER_PAGE); - await interaction.editReply({ - embeds: [embed], + const embed = new EmbedBuilder() + .setTitle('Pending Facts') + .setColor(0x0099ff) + .setDescription( + pageFacts + .map((fact) => { + return `**ID #${fact.id}**\n${fact.content}\nSubmitted by: <@${fact.addedBy}>\nSource: ${fact.source || 'Not provided'}`; + }) + .join('\n\n'), + ) + .setTimestamp(); + + pages.push(embed); + } + + let currentPage = 0; + + const message = await interaction.editReply({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + + if (pages.length <= 1) return; + + const collector = message.createMessageComponentCollector({ + time: 300000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'These controls are not for you!', + flags: ['Ephemeral'], + }); + return; + } + + if (i.isButton()) { + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; + } + } + + if (i.isStringSelectMenu()) { + const selected = parseInt(i.values[0]); + if (!isNaN(selected) && selected >= 0 && selected < pages.length) { + currentPage = selected; + } + } + + await i.update({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + }); + + collector.on('end', async () => { + if (message) { + try { + await interaction.editReply({ components: [] }); + } catch (error) { + console.error('Error removing components:', error); + } + } }); } else if (subcommand === 'post') { if ( diff --git a/src/commands/fun/giveaway.ts b/src/commands/fun/giveaway.ts index 833eba4..29a557c 100644 --- a/src/commands/fun/giveaway.ts +++ b/src/commands/fun/giveaway.ts @@ -17,6 +17,7 @@ import { formatWinnerMentions, builder, } from '@/util/giveaways/giveawayManager.js'; +import { createPaginationButtons } from '@/util/helpers.js'; const command: SubcommandCommand = { data: new SlashCommandBuilder() @@ -97,37 +98,103 @@ async function handleCreateGiveaway(interaction: ChatInputCommandInteraction) { */ async function handleListGiveaways(interaction: ChatInputCommandInteraction) { await interaction.deferReply(); + const GIVEAWAYS_PER_PAGE = 5; - const activeGiveaways = await getActiveGiveaways(); + try { + const activeGiveaways = await getActiveGiveaways(); - if (activeGiveaways.length === 0) { - await interaction.editReply('There are no active giveaways at the moment.'); - return; + if (activeGiveaways.length === 0) { + await interaction.editReply({ + content: 'There are no active giveaways at the moment.', + }); + return; + } + + const pages: EmbedBuilder[] = []; + for (let i = 0; i < activeGiveaways.length; i += GIVEAWAYS_PER_PAGE) { + const pageGiveaways = activeGiveaways.slice(i, i + GIVEAWAYS_PER_PAGE); + + const embed = new EmbedBuilder() + .setTitle('🎉 Active Giveaways') + .setColor(0x00ff00) + .setDescription('Here are the currently active giveaways:') + .setTimestamp(); + + pageGiveaways.forEach((giveaway) => { + embed.addFields({ + name: `${giveaway.prize} (ID: ${giveaway.id})`, + value: [ + `**Hosted by:** <@${giveaway.hostId}>`, + `**Winners:** ${giveaway.winnerCount}`, + `**Ends:** `, + `**Entries:** ${giveaway.participants?.length || 0}`, + `[Jump to Giveaway](https://discord.com/channels/${interaction.guildId}/${giveaway.channelId}/${giveaway.messageId})`, + ].join('\n'), + inline: false, + }); + }); + + pages.push(embed); + } + + let currentPage = 0; + + const message = await interaction.editReply({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + + const collector = message.createMessageComponentCollector({ + time: 300000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use these buttons.', + ephemeral: true, + }); + return; + } + + if (i.isButton()) { + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; + } + + await i.update({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + } + }); + + collector.on('end', async () => { + try { + await interaction.editReply({ + components: [], + }); + } catch (error) { + console.error('Error removing components:', error); + } + }); + } catch (error) { + console.error('Error fetching giveaways:', error); + await interaction.editReply({ + content: 'There was an error fetching the giveaways.', + }); } - - const embed = new EmbedBuilder() - .setTitle('🎉 Active Giveaways') - .setColor(0x00ff00) - .setTimestamp(); - - const giveawayDetails = activeGiveaways.map((g) => { - const channel = interaction.guild?.channels.cache.get(g.channelId); - const channelMention = channel ? `<#${channel.id}>` : 'Unknown channel'; - - return [ - `**Prize**: ${g.prize}`, - `**ID**: ${g.id}`, - `**Winners**: ${g.winnerCount}`, - `**Ends**: `, - `**Channel**: ${channelMention}`, - `**Entries**: ${g.participants?.length || 0}`, - '───────────────────', - ].join('\n'); - }); - - embed.setDescription(giveawayDetails.join('\n')); - - await interaction.editReply({ embeds: [embed] }); } /** diff --git a/src/commands/fun/leaderboard.ts b/src/commands/fun/leaderboard.ts index 02b3ad1..5d33eb4 100644 --- a/src/commands/fun/leaderboard.ts +++ b/src/commands/fun/leaderboard.ts @@ -1,9 +1,7 @@ import { SlashCommandBuilder, EmbedBuilder, - ButtonBuilder, ActionRowBuilder, - ButtonStyle, StringSelectMenuBuilder, APIEmbed, JSONEncodable, @@ -11,6 +9,7 @@ import { import { OptionsCommand } from '@/types/CommandTypes.js'; import { getLevelLeaderboard } from '@/db/db.js'; +import { createPaginationButtons } from '@/util/helpers.js'; const command: OptionsCommand = { data: new SlashCommandBuilder() @@ -78,18 +77,7 @@ const command: OptionsCommand = { let currentPage = 0; const getButtonActionRow = () => - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('previous') - .setLabel('Previous') - .setStyle(ButtonStyle.Primary) - .setDisabled(currentPage === 0), - new ButtonBuilder() - .setCustomId('next') - .setLabel('Next') - .setStyle(ButtonStyle.Primary) - .setDisabled(currentPage === pages.length - 1), - ); + createPaginationButtons(pages.length, currentPage); const getSelectMenuRow = () => { const options = pages.map((_, index) => ({ @@ -119,7 +107,7 @@ const command: OptionsCommand = { if (pages.length <= 1) return; const collector = message.createMessageComponentCollector({ - time: 60000, + time: 300000, }); collector.on('collect', async (i) => { @@ -132,10 +120,19 @@ const command: OptionsCommand = { } if (i.isButton()) { - if (i.customId === 'previous' && currentPage > 0) { - currentPage--; - } else if (i.customId === 'next' && currentPage < pages.length - 1) { - currentPage++; + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; } } diff --git a/src/commands/util/members.ts b/src/commands/util/members.ts index 6e3b8d9..6dd4762 100644 --- a/src/commands/util/members.ts +++ b/src/commands/util/members.ts @@ -1,9 +1,7 @@ import { SlashCommandBuilder, EmbedBuilder, - ButtonBuilder, ActionRowBuilder, - ButtonStyle, StringSelectMenuBuilder, APIEmbed, JSONEncodable, @@ -11,6 +9,7 @@ import { import { getAllMembers } from '@/db/db.js'; import { Command } from '@/types/CommandTypes.js'; +import { createPaginationButtons } from '@/util/helpers.js'; const command: Command = { data: new SlashCommandBuilder() @@ -42,18 +41,7 @@ const command: Command = { let currentPage = 0; const getButtonActionRow = () => - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('previous') - .setLabel('Previous') - .setStyle(ButtonStyle.Primary) - .setDisabled(currentPage === 0), - new ButtonBuilder() - .setCustomId('next') - .setLabel('Next') - .setStyle(ButtonStyle.Primary) - .setDisabled(currentPage === pages.length - 1), - ); + createPaginationButtons(pages.length, currentPage); const getSelectMenuRow = () => { const options = pages.map((_, index) => ({ @@ -85,7 +73,7 @@ const command: Command = { if (pages.length <= 1) return; const collector = message.createMessageComponentCollector({ - time: 60000, + time: 300000, }); collector.on('collect', async (i) => { @@ -98,10 +86,19 @@ const command: Command = { } if (i.isButton()) { - if (i.customId === 'previous' && currentPage > 0) { - currentPage--; - } else if (i.customId === 'next' && currentPage < pages.length - 1) { - currentPage++; + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; } } diff --git a/src/db/db.ts b/src/db/db.ts index 0baacd5..694b2b1 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,34 +1,44 @@ +// ======================== +// External Imports +// ======================== import pkg from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; -import { Client, Collection, GuildMember } from 'discord.js'; -import { and, desc, eq, isNull, sql } from 'drizzle-orm'; +import { Client } from 'discord.js'; +// ======================== +// Internal Imports +// ======================== import * as schema from './schema.js'; import { loadConfig } from '@/util/configLoader.js'; import { del, exists, getJson, setJson } from './redis.js'; -import { calculateLevelFromXp } from '@/util/levelingSystem.js'; -import { selectGiveawayWinners } from '@/util/giveaways/giveawayManager.js'; import { logManagerNotification, NotificationType, notifyManagers, } from '@/util/notificationHandler.js'; +// ======================== +// Database Configuration +// ======================== const { Pool } = pkg; const config = loadConfig(); -// Database connection state -let isDbConnected = false; -let connectionAttempts = 0; +// Connection parameters const MAX_DB_RETRY_ATTEMPTS = config.database.maxRetryAttempts; const INITIAL_DB_RETRY_DELAY = config.database.retryDelay; + +// ======================== +// Connection State Variables +// ======================== +let isDbConnected = false; +let connectionAttempts = 0; let hasNotifiedDbDisconnect = false; let discordClient: Client | null = null; let dbPool: pkg.Pool; export let db: ReturnType; /** - * Custom error class for database errors + * Custom error class for database operations */ class DatabaseError extends Error { constructor( @@ -40,6 +50,10 @@ class DatabaseError extends Error { } } +// ======================== +// Client Management +// ======================== + /** * Sets the Discord client for sending notifications * @param client - The Discord client @@ -48,11 +62,17 @@ export function setDiscordClient(client: Client): void { discordClient = client; } +// ======================== +// Connection Management +// ======================== + /** * Initializes the database connection with retry logic + * @returns Promise resolving to true if connected successfully, false otherwise */ export async function initializeDatabaseConnection(): Promise { try { + // Check if existing connection is working if (dbPool) { try { await dbPool.query('SELECT 1'); @@ -70,25 +90,30 @@ export async function initializeDatabaseConnection(): Promise { } } - // Log the database connection string (without sensitive info) + // Log the database connection attempt console.log( `Connecting to database... (connectionString length: ${config.database.dbConnectionString.length})`, ); + // Create new connection pool dbPool = new Pool({ connectionString: config.database.dbConnectionString, ssl: true, connectionTimeoutMillis: 10000, }); + // Test connection await dbPool.query('SELECT 1'); + // Initialize Drizzle ORM db = drizzle({ client: dbPool, schema }); + // Connection successful console.info('Successfully connected to database'); isDbConnected = true; connectionAttempts = 0; + // Send notification if connection was previously lost if (hasNotifiedDbDisconnect && discordClient) { logManagerNotification(NotificationType.DATABASE_CONNECTION_RESTORED); notifyManagers( @@ -104,6 +129,7 @@ export async function initializeDatabaseConnection(): Promise { isDbConnected = false; connectionAttempts++; + // Handle max retry attempts exceeded if (connectionAttempts >= MAX_DB_RETRY_ATTEMPTS) { if (!hasNotifiedDbDisconnect && discordClient) { const message = `Failed to connect to database after ${connectionAttempts} attempts.`; @@ -120,6 +146,7 @@ export async function initializeDatabaseConnection(): Promise { hasNotifiedDbDisconnect = true; } + // Terminate after sending notifications setTimeout(() => { console.error('Database connection failed, shutting down bot'); process.exit(1); @@ -128,7 +155,7 @@ export async function initializeDatabaseConnection(): Promise { return false; } - // Try to reconnect after delay with exponential backoff + // Retry connection with exponential backoff const delay = Math.min( INITIAL_DB_RETRY_DELAY * Math.pow(2, connectionAttempts - 1), 30000, @@ -143,12 +170,16 @@ export async function initializeDatabaseConnection(): Promise { } } -// Replace existing initialization with a properly awaited one +// Initialize database connection let dbInitPromise = initializeDatabaseConnection().catch((error) => { console.error('Failed to initialize database connection:', error); process.exit(1); }); +// ======================== +// Helper Functions +// ======================== + /** * Ensures the database is initialized and returns a promise * @returns Promise for database initialization @@ -183,18 +214,16 @@ export async function ensureDatabaseConnection(): Promise { } } -// ======================== -// Helper functions -// ======================== - /** * Generic error handler for database operations * @param errorMessage - Error message to log * @param error - Original error object + * @throws {DatabaseError} - Always throws a wrapped database error */ export const handleDbError = (errorMessage: string, error: Error): never => { console.error(`${errorMessage}:`, error); + // Check if error is related to connection and attempt to reconnect if ( error.message.includes('connection') || error.message.includes('connect') @@ -208,14 +237,18 @@ export const handleDbError = (errorMessage: string, error: Error): never => { throw new DatabaseError(errorMessage, error); }; +// ======================== +// Cache Management +// ======================== + /** * Checks and retrieves cached data or fetches from database * @param cacheKey - Key to check in cache * @param dbFetch - Function to fetch data from database - * @param ttl - Time to live for cache - * @returns Cached or fetched data + * @param ttl - Time to live for cache in seconds + * @returns Cached or freshly fetched data */ -async function withCache( +export async function withCache( cacheKey: string, dbFetch: () => Promise, ttl?: number, @@ -247,7 +280,7 @@ async function withCache( * Invalidates a cache key if it exists * @param cacheKey - Key to invalidate */ -async function invalidateCache(cacheKey: string): Promise { +export async function invalidateCache(cacheKey: string): Promise { try { if (await exists(cacheKey)) { await del(cacheKey); @@ -258,1313 +291,23 @@ async function invalidateCache(cacheKey: string): Promise { } // ======================== -// Member Functions +// Database Functions Exports // ======================== -/** - * Get all non-bot members currently in the server - * @returns Array of member objects - */ -export async function getAllMembers() { - try { - await ensureDbInitialized(); +// Achievement related functions +export * from './functions/achievementFunctions.js'; - if (!db) { - console.error('Database not initialized, cannot get members'); - } +// Facts system functions +export * from './functions/factFunctions.js'; - const cacheKey = 'nonBotMembers'; - return await withCache(cacheKey, async () => { - const nonBotMembers = await db - .select() - .from(schema.memberTable) - .where(eq(schema.memberTable.currentlyInServer, true)); - return nonBotMembers; - }); - } catch (error) { - return handleDbError('Failed to get all members', error as Error); - } -} +// Giveaway management functions +export * from './functions/giveawayFunctions.js'; -/** - * Set or update multiple members at once - * @param nonBotMembers - Array of member objects - */ -export async function setMembers( - nonBotMembers: Collection, -): Promise { - try { - await ensureDbInitialized(); +// User leveling system functions +export * from './functions/levelFunctions.js'; - if (!db) { - console.error('Database not initialized, cannot set members'); - } +// Guild member management functions +export * from './functions/memberFunctions.js'; - await Promise.all( - nonBotMembers.map(async (member) => { - const memberInfo = await db - .select() - .from(schema.memberTable) - .where(eq(schema.memberTable.discordId, member.user.id)); - - if (memberInfo.length > 0) { - await updateMember({ - discordId: member.user.id, - discordUsername: member.user.username, - currentlyInServer: true, - }); - } else { - const members: typeof schema.memberTable.$inferInsert = { - discordId: member.user.id, - discordUsername: member.user.username, - }; - await db.insert(schema.memberTable).values(members); - } - }), - ); - } catch (error) { - handleDbError('Failed to set members', error as Error); - } -} - -/** - * Get detailed information about a specific member including moderation history - * @param discordId - Discord ID of the user - * @returns Member object with moderation history - */ -export async function getMember( - discordId: string, -): Promise< - | (schema.memberTableTypes & { moderations: schema.moderationTableTypes[] }) - | undefined -> { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot get member'); - } - - const cacheKey = `${discordId}-memberInfo`; - - const member = await withCache( - cacheKey, - async () => { - const memberData = await db - .select() - .from(schema.memberTable) - .where(eq(schema.memberTable.discordId, discordId)) - .then((rows) => rows[0]); - - return memberData as schema.memberTableTypes; - }, - ); - - const moderations = await getMemberModerationHistory(discordId); - - return { - ...member, - moderations, - }; - } catch (error) { - return handleDbError('Failed to get member', error as Error); - } -} - -/** - * Update a member's information in the database - * @param discordId - Discord ID of the user - * @param discordUsername - New username of the member - * @param currentlyInServer - Whether the member is currently in the server - * @param currentlyBanned - Whether the member is currently banned - */ -export async function updateMember({ - discordId, - discordUsername, - currentlyInServer, - currentlyBanned, -}: schema.memberTableTypes): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot update member'); - } - - await db - .update(schema.memberTable) - .set({ - discordUsername, - currentlyInServer, - currentlyBanned, - }) - .where(eq(schema.memberTable.discordId, discordId)); - - await Promise.all([ - invalidateCache(`${discordId}-memberInfo`), - invalidateCache('nonBotMembers'), - ]); - } catch (error) { - handleDbError('Failed to update member', error as Error); - } -} - -// ======================== -// Level & XP Functions -// ======================== - -/** - * Get user level information or create a new entry if not found - * @param discordId - Discord ID of the user - * @returns User level object - */ -export async function getUserLevel( - discordId: string, -): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot get user level'); - } - - const cacheKey = `level-${discordId}`; - - return await withCache( - cacheKey, - async () => { - const level = await db - .select() - .from(schema.levelTable) - .where(eq(schema.levelTable.discordId, discordId)) - .then((rows) => rows[0]); - - if (level) { - return { - ...level, - lastMessageTimestamp: level.lastMessageTimestamp ?? undefined, - }; - } - - const newLevel: schema.levelTableTypes = { - discordId, - xp: 0, - level: 0, - lastMessageTimestamp: new Date(), - messagesSent: 0, - reactionCount: 0, - }; - - await db.insert(schema.levelTable).values(newLevel); - return newLevel; - }, - 300, - ); - } catch (error) { - return handleDbError('Error getting user level', error as Error); - } -} - -/** - * Add XP to a user, updating their level if necessary - * @param discordId - Discord ID of the user - * @param amount - Amount of XP to add - */ -export async function addXpToUser( - discordId: string, - amount: number, -): Promise<{ - leveledUp: boolean; - newLevel: number; - oldLevel: number; - messagesSent: number; -}> { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot add xp to user'); - } - - const cacheKey = `level-${discordId}`; - const userData = await getUserLevel(discordId); - const currentLevel = userData.level; - const currentXp = Number(userData.xp); - const xpToAdd = Number(amount); - - userData.xp = currentXp + xpToAdd; - - userData.lastMessageTimestamp = new Date(); - userData.level = calculateLevelFromXp(userData.xp); - userData.messagesSent += 1; - - await invalidateLeaderboardCache(); - await invalidateCache(cacheKey); - await withCache( - cacheKey, - async () => { - const result = await db - .update(schema.levelTable) - .set({ - xp: userData.xp, - level: userData.level, - lastMessageTimestamp: userData.lastMessageTimestamp, - messagesSent: userData.messagesSent, - }) - .where(eq(schema.levelTable.discordId, discordId)) - .returning(); - - return result[0] as schema.levelTableTypes; - }, - 300, - ); - - return { - leveledUp: userData.level > currentLevel, - newLevel: userData.level, - oldLevel: currentLevel, - messagesSent: userData.messagesSent, - }; - } catch (error) { - return handleDbError('Error adding XP to user', error as Error); - } -} - -/** - * Get a user's rank on the XP leaderboard - * @param discordId - Discord ID of the user - * @returns User's rank on the leaderboard - */ -export async function getUserRank(discordId: string): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot get user rank'); - } - - const leaderboardCache = await getLeaderboardData(); - - if (leaderboardCache) { - const userIndex = leaderboardCache.findIndex( - (member) => member.discordId === discordId, - ); - - if (userIndex !== -1) { - return userIndex + 1; - } - } - - return 1; - } catch (error) { - return handleDbError('Failed to get user rank', error as Error); - } -} - -/** - * Clear leaderboard cache - */ -export async function invalidateLeaderboardCache(): Promise { - await invalidateCache('xp-leaderboard'); -} - -/** - * Helper function to get or create leaderboard data - * @returns Array of leaderboard data - */ -async function getLeaderboardData(): Promise< - Array<{ - discordId: string; - xp: number; - }> -> { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot get leaderboard data'); - } - - const cacheKey = 'xp-leaderboard'; - return withCache>( - cacheKey, - async () => { - return await db - .select({ - discordId: schema.levelTable.discordId, - xp: schema.levelTable.xp, - }) - .from(schema.levelTable) - .orderBy(desc(schema.levelTable.xp)); - }, - 300, - ); - } catch (error) { - return handleDbError('Failed to get leaderboard data', error as Error); - } -} - -/** - * Increments the user's reaction count - * @param userId - Discord user ID - * @returns The updated reaction count - */ -export async function incrementUserReactionCount( - userId: string, -): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error( - 'Database not initialized, cannot increment reaction count', - ); - } - - const levelData = await getUserLevel(userId); - - const newCount = (levelData.reactionCount || 0) + 1; - await db - .update(schema.levelTable) - .set({ reactionCount: newCount }) - .where(eq(schema.levelTable.discordId, userId)); - await invalidateCache(`level-${userId}`); - - return newCount; - } catch (error) { - console.error('Error incrementing user reaction count:', error); - return 0; - } -} - -/** - * Decrements the user's reaction count (but not below zero) - * @param userId - Discord user ID - * @returns The updated reaction count - */ -export async function decrementUserReactionCount( - userId: string, -): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error( - 'Database not initialized, cannot increment reaction count', - ); - } - - const levelData = await getUserLevel(userId); - - const newCount = Math.max(0, levelData.reactionCount - 1); - await db - .update(schema.levelTable) - .set({ reactionCount: newCount < 0 ? 0 : newCount }) - .where(eq(schema.levelTable.discordId, userId)); - await invalidateCache(`level-${userId}`); - - return newCount; - } catch (error) { - console.error('Error decrementing user reaction count:', error); - return 0; - } -} - -/** - * Gets the user's reaction count - * @param userId - Discord user ID - * @returns The user's reaction count - */ -export async function getUserReactionCount(userId: string): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot get user reaction count'); - } - - const levelData = await getUserLevel(userId); - return levelData.reactionCount; - } catch (error) { - console.error('Error getting user reaction count:', error); - return 0; - } -} - -/** - * Get the XP leaderboard - * @param limit - Number of entries to return - * @returns Array of leaderboard entries - */ -export async function getLevelLeaderboard( - limit = 10, -): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot get level leaderboard'); - } - - const leaderboardCache = await getLeaderboardData(); - - if (leaderboardCache) { - const limitedCache = leaderboardCache.slice(0, limit); - - const fullLeaderboard = await Promise.all( - limitedCache.map(async (entry) => { - const userData = await getUserLevel(entry.discordId); - return userData; - }), - ); - - return fullLeaderboard; - } - - return (await db - .select() - .from(schema.levelTable) - .orderBy(desc(schema.levelTable.xp)) - .limit(limit)) as schema.levelTableTypes[]; - } catch (error) { - return handleDbError('Failed to get leaderboard', error as Error); - } -} - -// ======================== -// Moderation Functions -// ======================== - -/** - * Add a new moderation action to a member's history - * @param discordId - Discord ID of the user - * @param moderatorDiscordId - Discord ID of the moderator - * @param action - Type of action taken - * @param reason - Reason for the action - * @param duration - Duration of the action - * @param createdAt - Timestamp of when the action was taken - * @param expiresAt - Timestamp of when the action expires - * @param active - Wether the action is active or not - */ -export async function updateMemberModerationHistory({ - discordId, - moderatorDiscordId, - action, - reason, - duration, - createdAt, - expiresAt, - active, -}: schema.moderationTableTypes): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error( - 'Database not initialized, update member moderation history', - ); - } - - const moderationEntry = { - discordId, - moderatorDiscordId, - action, - reason, - duration, - createdAt, - expiresAt, - active, - }; - - await db.insert(schema.moderationTable).values(moderationEntry); - - await Promise.all([ - invalidateCache(`${discordId}-moderationHistory`), - invalidateCache(`${discordId}-memberInfo`), - ]); - } catch (error) { - handleDbError('Failed to update moderation history', error as Error); - } -} - -/** - * Get a member's moderation history - * @param discordId - Discord ID of the user - * @returns Array of moderation actions - */ -export async function getMemberModerationHistory( - discordId: string, -): Promise { - await ensureDbInitialized(); - - if (!db) { - console.error( - 'Database not initialized, cannot get member moderation history', - ); - } - - const cacheKey = `${discordId}-moderationHistory`; - - try { - return await withCache( - cacheKey, - async () => { - const history = await db - .select() - .from(schema.moderationTable) - .where(eq(schema.moderationTable.discordId, discordId)); - return history as schema.moderationTableTypes[]; - }, - ); - } catch (error) { - return handleDbError('Failed to get moderation history', error as Error); - } -} - -// ======================== -// Fact Functions -// ======================== - -/** - * Add a new fact to the database - * @param content - Content of the fact - * @param source - Source of the fact - * @param addedBy - Discord ID of the user who added the fact - * @param approved - Whether the fact is approved or not - */ -export async function addFact({ - content, - source, - addedBy, - approved = false, -}: schema.factTableTypes): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot add fact'); - } - - await db.insert(schema.factTable).values({ - content, - source, - addedBy, - approved, - }); - - await invalidateCache('unused-facts'); - } catch (error) { - handleDbError('Failed to add fact', error as Error); - } -} - -/** - * Get the ID of the most recently added fact - * @returns ID of the last inserted fact - */ -export async function getLastInsertedFactId(): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot get last inserted fact'); - } - - const result = await db - .select({ id: sql`MAX(${schema.factTable.id})` }) - .from(schema.factTable); - - return result[0]?.id ?? 0; - } catch (error) { - return handleDbError('Failed to get last inserted fact ID', error as Error); - } -} - -/** - * Get a random fact that hasn't been used yet - * @returns Random fact object - */ -export async function getRandomUnusedFact(): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot get random unused fact'); - } - - const cacheKey = 'unused-facts'; - const facts = await withCache( - cacheKey, - async () => { - return (await db - .select() - .from(schema.factTable) - .where( - and( - eq(schema.factTable.approved, true), - isNull(schema.factTable.usedOn), - ), - )) as schema.factTableTypes[]; - }, - ); - - if (facts.length === 0) { - await db - .update(schema.factTable) - .set({ usedOn: null }) - .where(eq(schema.factTable.approved, true)); - - await invalidateCache(cacheKey); - return await getRandomUnusedFact(); - } - - return facts[ - Math.floor(Math.random() * facts.length) - ] as schema.factTableTypes; - } catch (error) { - return handleDbError('Failed to get random fact', error as Error); - } -} - -/** - * Mark a fact as used - * @param id - ID of the fact to mark as used - */ -export async function markFactAsUsed(id: number): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot mark fact as used'); - } - - await db - .update(schema.factTable) - .set({ usedOn: new Date() }) - .where(eq(schema.factTable.id, id)); - - await invalidateCache('unused-facts'); - } catch (error) { - handleDbError('Failed to mark fact as used', error as Error); - } -} - -/** - * Get all pending facts that need approval - * @returns Array of pending fact objects - */ -export async function getPendingFacts(): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot get pending facts'); - } - - return (await db - .select() - .from(schema.factTable) - .where(eq(schema.factTable.approved, false))) as schema.factTableTypes[]; - } catch (error) { - return handleDbError('Failed to get pending facts', error as Error); - } -} - -/** - * Approve a fact - * @param id - ID of the fact to approve - */ -export async function approveFact(id: number): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot approve fact'); - } - - await db - .update(schema.factTable) - .set({ approved: true }) - .where(eq(schema.factTable.id, id)); - - await invalidateCache('unused-facts'); - } catch (error) { - handleDbError('Failed to approve fact', error as Error); - } -} - -/** - * Delete a fact - * @param id - ID of the fact to delete - */ -export async function deleteFact(id: number): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot delete fact'); - } - - await db.delete(schema.factTable).where(eq(schema.factTable.id, id)); - - await invalidateCache('unused-facts'); - } catch (error) { - 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 { - 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 { - 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 { - 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 { - 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); - } -} - -// ======================== -// Achievement Functions -// ======================== - -/** - * Get all achievement definitions - * @returns Array of achievement definitions - */ -export async function getAllAchievements(): Promise< - schema.achievementDefinitionsTableTypes[] -> { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot get achievements'); - return []; - } - - return await db - .select() - .from(schema.achievementDefinitionsTable) - .orderBy(schema.achievementDefinitionsTable.threshold); - } catch (error) { - return handleDbError('Failed to get all achievements', error as Error); - } -} - -/** - * Get achievements for a specific user - * @param userId - Discord ID of the user - * @returns Array of user achievements - */ -export async function getUserAchievements( - userId: string, -): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot get user achievements'); - return []; - } - - return await db - .select({ - id: schema.userAchievementsTable.id, - discordId: schema.userAchievementsTable.discordId, - achievementId: schema.userAchievementsTable.achievementId, - earnedAt: schema.userAchievementsTable.earnedAt, - progress: schema.userAchievementsTable.progress, - }) - .from(schema.userAchievementsTable) - .where(eq(schema.userAchievementsTable.discordId, userId)); - } catch (error) { - return handleDbError('Failed to get user achievements', error as Error); - } -} - -/** - * Award an achievement to a user - * @param userId - Discord ID of the user - * @param achievementId - ID of the achievement - * @returns Boolean indicating success - */ -export async function awardAchievement( - userId: string, - achievementId: number, -): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot award achievement'); - return false; - } - - const existing = await db - .select() - .from(schema.userAchievementsTable) - .where( - and( - eq(schema.userAchievementsTable.discordId, userId), - eq(schema.userAchievementsTable.achievementId, achievementId), - ), - ) - .then((rows) => rows[0]); - - if (existing) { - if (existing.earnedAt) { - return false; - } - - await db - .update(schema.userAchievementsTable) - .set({ - earnedAt: new Date(), - progress: 100, - }) - .where(eq(schema.userAchievementsTable.id, existing.id)); - } else { - await db.insert(schema.userAchievementsTable).values({ - discordId: userId, - achievementId: achievementId, - earnedAt: new Date(), - progress: 100, - }); - } - - return true; - } catch (error) { - handleDbError('Failed to award achievement', error as Error); - return false; - } -} - -/** - * Update achievement progress for a user - * @param userId - Discord ID of the user - * @param achievementId - ID of the achievement - * @param progress - Progress value (0-100) - * @returns Boolean indicating success - */ -export async function updateAchievementProgress( - userId: string, - achievementId: number, - progress: number, -): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error( - 'Database not initialized, cannot update achievement progress', - ); - return false; - } - - const existing = await db - .select() - .from(schema.userAchievementsTable) - .where( - and( - eq(schema.userAchievementsTable.discordId, userId), - eq(schema.userAchievementsTable.achievementId, achievementId), - ), - ) - .then((rows) => rows[0]); - - if (existing) { - if (existing.earnedAt) { - return false; - } - - await db - .update(schema.userAchievementsTable) - .set({ - progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress), - }) - .where(eq(schema.userAchievementsTable.id, existing.id)); - } else { - await db.insert(schema.userAchievementsTable).values({ - discordId: userId, - achievementId: achievementId, - progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress), - }); - } - - return true; - } catch (error) { - handleDbError('Failed to update achievement progress', error as Error); - return false; - } -} - -/** - * Create a new achievement definition - * @param achievementData - Achievement definition data - * @returns Created achievement or undefined on failure - */ -export async function createAchievement(achievementData: { - name: string; - description: string; - imageUrl?: string; - requirementType: string; - threshold: number; - requirement?: any; - rewardType?: string; - rewardValue?: string; -}): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot create achievement'); - return undefined; - } - - const [achievement] = await db - .insert(schema.achievementDefinitionsTable) - .values({ - name: achievementData.name, - description: achievementData.description, - imageUrl: achievementData.imageUrl || null, - requirementType: achievementData.requirementType, - threshold: achievementData.threshold, - requirement: achievementData.requirement || {}, - rewardType: achievementData.rewardType || null, - rewardValue: achievementData.rewardValue || null, - }) - .returning(); - - return achievement; - } catch (error) { - return handleDbError('Failed to create achievement', error as Error); - } -} - -/** - * Delete an achievement definition - * @param achievementId - ID of the achievement to delete - * @returns Boolean indicating success - */ -export async function deleteAchievement( - achievementId: number, -): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot delete achievement'); - return false; - } - - await db - .delete(schema.userAchievementsTable) - .where(eq(schema.userAchievementsTable.achievementId, achievementId)); - - await db - .delete(schema.achievementDefinitionsTable) - .where(eq(schema.achievementDefinitionsTable.id, achievementId)); - - return true; - } catch (error) { - handleDbError('Failed to delete achievement', error as Error); - return false; - } -} - -/** - * Removes an achievement from a user - * @param discordId - Discord user ID - * @param achievementId - Achievement ID to remove - * @returns boolean indicating success - */ -export async function removeUserAchievement( - discordId: string, - achievementId: number, -): Promise { - try { - await ensureDbInitialized(); - - if (!db) { - console.error('Database not initialized, cannot remove user achievement'); - return false; - } - - await db - .delete(schema.userAchievementsTable) - .where( - and( - eq(schema.userAchievementsTable.discordId, discordId), - eq(schema.userAchievementsTable.achievementId, achievementId), - ), - ); - return true; - } catch (error) { - handleDbError('Failed to remove user achievement', error as Error); - return false; - } -} +// Moderation and administration functions +export * from './functions/moderationFunctions.js'; diff --git a/src/db/functions/achievementFunctions.ts b/src/db/functions/achievementFunctions.ts new file mode 100644 index 0000000..30cbc3b --- /dev/null +++ b/src/db/functions/achievementFunctions.ts @@ -0,0 +1,282 @@ +import { and, eq } from 'drizzle-orm'; + +import { db, ensureDbInitialized, handleDbError } from '../db.js'; +import * as schema from '../schema.js'; + +/** + * Get all achievement definitions + * @returns Array of achievement definitions + */ +export async function getAllAchievements(): Promise< + schema.achievementDefinitionsTableTypes[] +> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get achievements'); + return []; + } + + return await db + .select() + .from(schema.achievementDefinitionsTable) + .orderBy(schema.achievementDefinitionsTable.threshold); + } catch (error) { + return handleDbError('Failed to get all achievements', error as Error); + } +} + +/** + * Get achievements for a specific user + * @param userId - Discord ID of the user + * @returns Array of user achievements + */ +export async function getUserAchievements( + userId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user achievements'); + return []; + } + + return await db + .select({ + id: schema.userAchievementsTable.id, + discordId: schema.userAchievementsTable.discordId, + achievementId: schema.userAchievementsTable.achievementId, + earnedAt: schema.userAchievementsTable.earnedAt, + progress: schema.userAchievementsTable.progress, + }) + .from(schema.userAchievementsTable) + .where(eq(schema.userAchievementsTable.discordId, userId)); + } catch (error) { + return handleDbError('Failed to get user achievements', error as Error); + } +} + +/** + * Award an achievement to a user + * @param userId - Discord ID of the user + * @param achievementId - ID of the achievement + * @returns Boolean indicating success + */ +export async function awardAchievement( + userId: string, + achievementId: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot award achievement'); + return false; + } + + const existing = await db + .select() + .from(schema.userAchievementsTable) + .where( + and( + eq(schema.userAchievementsTable.discordId, userId), + eq(schema.userAchievementsTable.achievementId, achievementId), + ), + ) + .then((rows) => rows[0]); + + if (existing) { + if (existing.earnedAt) { + return false; + } + + await db + .update(schema.userAchievementsTable) + .set({ + earnedAt: new Date(), + progress: 100, + }) + .where(eq(schema.userAchievementsTable.id, existing.id)); + } else { + await db.insert(schema.userAchievementsTable).values({ + discordId: userId, + achievementId: achievementId, + earnedAt: new Date(), + progress: 100, + }); + } + + return true; + } catch (error) { + handleDbError('Failed to award achievement', error as Error); + return false; + } +} + +/** + * Update achievement progress for a user + * @param userId - Discord ID of the user + * @param achievementId - ID of the achievement + * @param progress - Progress value (0-100) + * @returns Boolean indicating success + */ +export async function updateAchievementProgress( + userId: string, + achievementId: number, + progress: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot update achievement progress', + ); + return false; + } + + const existing = await db + .select() + .from(schema.userAchievementsTable) + .where( + and( + eq(schema.userAchievementsTable.discordId, userId), + eq(schema.userAchievementsTable.achievementId, achievementId), + ), + ) + .then((rows) => rows[0]); + + if (existing) { + if (existing.earnedAt) { + return false; + } + + await db + .update(schema.userAchievementsTable) + .set({ + progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress), + }) + .where(eq(schema.userAchievementsTable.id, existing.id)); + } else { + await db.insert(schema.userAchievementsTable).values({ + discordId: userId, + achievementId: achievementId, + progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress), + }); + } + + return true; + } catch (error) { + handleDbError('Failed to update achievement progress', error as Error); + return false; + } +} + +/** + * Create a new achievement definition + * @param achievementData - Achievement definition data + * @returns Created achievement or undefined on failure + */ +export async function createAchievement(achievementData: { + name: string; + description: string; + imageUrl?: string; + requirementType: string; + threshold: number; + requirement?: any; + rewardType?: string; + rewardValue?: string; +}): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot create achievement'); + return undefined; + } + + const [achievement] = await db + .insert(schema.achievementDefinitionsTable) + .values({ + name: achievementData.name, + description: achievementData.description, + imageUrl: achievementData.imageUrl || null, + requirementType: achievementData.requirementType, + threshold: achievementData.threshold, + requirement: achievementData.requirement || {}, + rewardType: achievementData.rewardType || null, + rewardValue: achievementData.rewardValue || null, + }) + .returning(); + + return achievement; + } catch (error) { + return handleDbError('Failed to create achievement', error as Error); + } +} + +/** + * Delete an achievement definition + * @param achievementId - ID of the achievement to delete + * @returns Boolean indicating success + */ +export async function deleteAchievement( + achievementId: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot delete achievement'); + return false; + } + + await db + .delete(schema.userAchievementsTable) + .where(eq(schema.userAchievementsTable.achievementId, achievementId)); + + await db + .delete(schema.achievementDefinitionsTable) + .where(eq(schema.achievementDefinitionsTable.id, achievementId)); + + return true; + } catch (error) { + handleDbError('Failed to delete achievement', error as Error); + return false; + } +} + +/** + * Removes an achievement from a user + * @param discordId - Discord user ID + * @param achievementId - Achievement ID to remove + * @returns boolean indicating success + */ +export async function removeUserAchievement( + discordId: string, + achievementId: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot remove user achievement'); + return false; + } + + await db + .delete(schema.userAchievementsTable) + .where( + and( + eq(schema.userAchievementsTable.discordId, discordId), + eq(schema.userAchievementsTable.achievementId, achievementId), + ), + ); + return true; + } catch (error) { + handleDbError('Failed to remove user achievement', error as Error); + return false; + } +} diff --git a/src/db/functions/factFunctions.ts b/src/db/functions/factFunctions.ts new file mode 100644 index 0000000..10134f3 --- /dev/null +++ b/src/db/functions/factFunctions.ts @@ -0,0 +1,198 @@ +import { and, eq, isNull, sql } from 'drizzle-orm'; + +import { + db, + ensureDbInitialized, + handleDbError, + invalidateCache, + withCache, +} from '../db.js'; +import * as schema from '../schema.js'; + +/** + * Add a new fact to the database + * @param content - Content of the fact + * @param source - Source of the fact + * @param addedBy - Discord ID of the user who added the fact + * @param approved - Whether the fact is approved or not + */ +export async function addFact({ + content, + source, + addedBy, + approved = false, +}: schema.factTableTypes): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot add fact'); + } + + await db.insert(schema.factTable).values({ + content, + source, + addedBy, + approved, + }); + + await invalidateCache('unused-facts'); + } catch (error) { + handleDbError('Failed to add fact', error as Error); + } +} + +/** + * Get the ID of the most recently added fact + * @returns ID of the last inserted fact + */ +export async function getLastInsertedFactId(): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get last inserted fact'); + } + + const result = await db + .select({ id: sql`MAX(${schema.factTable.id})` }) + .from(schema.factTable); + + return result[0]?.id ?? 0; + } catch (error) { + return handleDbError('Failed to get last inserted fact ID', error as Error); + } +} + +/** + * Get a random fact that hasn't been used yet + * @returns Random fact object + */ +export async function getRandomUnusedFact(): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get random unused fact'); + } + + const cacheKey = 'unused-facts'; + const facts = await withCache( + cacheKey, + async () => { + return (await db + .select() + .from(schema.factTable) + .where( + and( + eq(schema.factTable.approved, true), + isNull(schema.factTable.usedOn), + ), + )) as schema.factTableTypes[]; + }, + ); + + if (facts.length === 0) { + await db + .update(schema.factTable) + .set({ usedOn: null }) + .where(eq(schema.factTable.approved, true)); + + await invalidateCache(cacheKey); + return await getRandomUnusedFact(); + } + + return facts[ + Math.floor(Math.random() * facts.length) + ] as schema.factTableTypes; + } catch (error) { + return handleDbError('Failed to get random fact', error as Error); + } +} + +/** + * Mark a fact as used + * @param id - ID of the fact to mark as used + */ +export async function markFactAsUsed(id: number): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot mark fact as used'); + } + + await db + .update(schema.factTable) + .set({ usedOn: new Date() }) + .where(eq(schema.factTable.id, id)); + + await invalidateCache('unused-facts'); + } catch (error) { + handleDbError('Failed to mark fact as used', error as Error); + } +} + +/** + * Get all pending facts that need approval + * @returns Array of pending fact objects + */ +export async function getPendingFacts(): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get pending facts'); + } + + return (await db + .select() + .from(schema.factTable) + .where(eq(schema.factTable.approved, false))) as schema.factTableTypes[]; + } catch (error) { + return handleDbError('Failed to get pending facts', error as Error); + } +} + +/** + * Approve a fact + * @param id - ID of the fact to approve + */ +export async function approveFact(id: number): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot approve fact'); + } + + await db + .update(schema.factTable) + .set({ approved: true }) + .where(eq(schema.factTable.id, id)); + + await invalidateCache('unused-facts'); + } catch (error) { + handleDbError('Failed to approve fact', error as Error); + } +} + +/** + * Delete a fact + * @param id - ID of the fact to delete + */ +export async function deleteFact(id: number): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot delete fact'); + } + + await db.delete(schema.factTable).where(eq(schema.factTable.id, id)); + + await invalidateCache('unused-facts'); + } catch (error) { + return handleDbError('Failed to delete fact', error as Error); + } +} diff --git a/src/db/functions/giveawayFunctions.ts b/src/db/functions/giveawayFunctions.ts new file mode 100644 index 0000000..354b07f --- /dev/null +++ b/src/db/functions/giveawayFunctions.ts @@ -0,0 +1,275 @@ +import { eq } from 'drizzle-orm'; + +import { db, ensureDbInitialized, handleDbError } from '../db.js'; +import { selectGiveawayWinners } from '@/util/giveaways/utils.js'; +import * as schema from '../schema.js'; + +/** + * 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/db/functions/levelFunctions.ts b/src/db/functions/levelFunctions.ts new file mode 100644 index 0000000..3bfd42a --- /dev/null +++ b/src/db/functions/levelFunctions.ts @@ -0,0 +1,329 @@ +import { desc, eq } from 'drizzle-orm'; + +import { + db, + ensureDbInitialized, + handleDbError, + invalidateCache, + withCache, +} from '../db.js'; +import * as schema from '../schema.js'; +import { calculateLevelFromXp } from '@/util/levelingSystem.js'; + +/** + * Get user level information or create a new entry if not found + * @param discordId - Discord ID of the user + * @returns User level object + */ +export async function getUserLevel( + discordId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user level'); + } + + const cacheKey = `level-${discordId}`; + + return await withCache( + cacheKey, + async () => { + const level = await db + .select() + .from(schema.levelTable) + .where(eq(schema.levelTable.discordId, discordId)) + .then((rows) => rows[0]); + + if (level) { + return { + ...level, + lastMessageTimestamp: level.lastMessageTimestamp ?? undefined, + }; + } + + const newLevel: schema.levelTableTypes = { + discordId, + xp: 0, + level: 0, + lastMessageTimestamp: new Date(), + messagesSent: 0, + reactionCount: 0, + }; + + await db.insert(schema.levelTable).values(newLevel); + return newLevel; + }, + 300, + ); + } catch (error) { + return handleDbError('Error getting user level', error as Error); + } +} + +/** + * Add XP to a user, updating their level if necessary + * @param discordId - Discord ID of the user + * @param amount - Amount of XP to add + */ +export async function addXpToUser( + discordId: string, + amount: number, +): Promise<{ + leveledUp: boolean; + newLevel: number; + oldLevel: number; + messagesSent: number; +}> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot add xp to user'); + } + + const cacheKey = `level-${discordId}`; + const userData = await getUserLevel(discordId); + const currentLevel = userData.level; + const currentXp = Number(userData.xp); + const xpToAdd = Number(amount); + + userData.xp = currentXp + xpToAdd; + + userData.lastMessageTimestamp = new Date(); + userData.level = calculateLevelFromXp(userData.xp); + userData.messagesSent += 1; + + await invalidateLeaderboardCache(); + await invalidateCache(cacheKey); + await withCache( + cacheKey, + async () => { + const result = await db + .update(schema.levelTable) + .set({ + xp: userData.xp, + level: userData.level, + lastMessageTimestamp: userData.lastMessageTimestamp, + messagesSent: userData.messagesSent, + }) + .where(eq(schema.levelTable.discordId, discordId)) + .returning(); + + return result[0] as schema.levelTableTypes; + }, + 300, + ); + + return { + leveledUp: userData.level > currentLevel, + newLevel: userData.level, + oldLevel: currentLevel, + messagesSent: userData.messagesSent, + }; + } catch (error) { + return handleDbError('Error adding XP to user', error as Error); + } +} + +/** + * Get a user's rank on the XP leaderboard + * @param discordId - Discord ID of the user + * @returns User's rank on the leaderboard + */ +export async function getUserRank(discordId: string): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user rank'); + } + + const leaderboardCache = await getLeaderboardData(); + + if (leaderboardCache) { + const userIndex = leaderboardCache.findIndex( + (member) => member.discordId === discordId, + ); + + if (userIndex !== -1) { + return userIndex + 1; + } + } + + return 1; + } catch (error) { + return handleDbError('Failed to get user rank', error as Error); + } +} + +/** + * Clear leaderboard cache + */ +export async function invalidateLeaderboardCache(): Promise { + await invalidateCache('xp-leaderboard'); +} + +/** + * Helper function to get or create leaderboard data + * @returns Array of leaderboard data + */ +async function getLeaderboardData(): Promise< + Array<{ + discordId: string; + xp: number; + }> +> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get leaderboard data'); + } + + const cacheKey = 'xp-leaderboard'; + return withCache>( + cacheKey, + async () => { + return await db + .select({ + discordId: schema.levelTable.discordId, + xp: schema.levelTable.xp, + }) + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)); + }, + 300, + ); + } catch (error) { + return handleDbError('Failed to get leaderboard data', error as Error); + } +} + +/** + * Increments the user's reaction count + * @param userId - Discord user ID + * @returns The updated reaction count + */ +export async function incrementUserReactionCount( + userId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot increment reaction count', + ); + } + + const levelData = await getUserLevel(userId); + + const newCount = (levelData.reactionCount || 0) + 1; + await db + .update(schema.levelTable) + .set({ reactionCount: newCount }) + .where(eq(schema.levelTable.discordId, userId)); + await invalidateCache(`level-${userId}`); + + return newCount; + } catch (error) { + console.error('Error incrementing user reaction count:', error); + return 0; + } +} + +/** + * Decrements the user's reaction count (but not below zero) + * @param userId - Discord user ID + * @returns The updated reaction count + */ +export async function decrementUserReactionCount( + userId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot increment reaction count', + ); + } + + const levelData = await getUserLevel(userId); + + const newCount = Math.max(0, levelData.reactionCount - 1); + await db + .update(schema.levelTable) + .set({ reactionCount: newCount < 0 ? 0 : newCount }) + .where(eq(schema.levelTable.discordId, userId)); + await invalidateCache(`level-${userId}`); + + return newCount; + } catch (error) { + console.error('Error decrementing user reaction count:', error); + return 0; + } +} + +/** + * Gets the user's reaction count + * @param userId - Discord user ID + * @returns The user's reaction count + */ +export async function getUserReactionCount(userId: string): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user reaction count'); + } + + const levelData = await getUserLevel(userId); + return levelData.reactionCount; + } catch (error) { + console.error('Error getting user reaction count:', error); + return 0; + } +} + +/** + * Get the XP leaderboard + * @param limit - Number of entries to return + * @returns Array of leaderboard entries + */ +export async function getLevelLeaderboard( + limit = 10, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get level leaderboard'); + } + + const leaderboardCache = await getLeaderboardData(); + + if (leaderboardCache) { + const limitedCache = leaderboardCache.slice(0, limit); + + const fullLeaderboard = await Promise.all( + limitedCache.map(async (entry) => { + const userData = await getUserLevel(entry.discordId); + return userData; + }), + ); + + return fullLeaderboard; + } + + return (await db + .select() + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)) + .limit(limit)) as schema.levelTableTypes[]; + } catch (error) { + return handleDbError('Failed to get leaderboard', error as Error); + } +} diff --git a/src/db/functions/memberFunctions.ts b/src/db/functions/memberFunctions.ts new file mode 100644 index 0000000..aa72b30 --- /dev/null +++ b/src/db/functions/memberFunctions.ts @@ -0,0 +1,160 @@ +import { Collection, GuildMember } from 'discord.js'; +import { eq } from 'drizzle-orm'; + +import { + db, + ensureDbInitialized, + handleDbError, + invalidateCache, + withCache, +} from '../db.js'; +import * as schema from '../schema.js'; +import { getMemberModerationHistory } from './moderationFunctions.js'; + +/** + * Get all non-bot members currently in the server + * @returns Array of member objects + */ +export async function getAllMembers() { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get members'); + } + + const cacheKey = 'nonBotMembers'; + return await withCache(cacheKey, async () => { + const nonBotMembers = await db + .select() + .from(schema.memberTable) + .where(eq(schema.memberTable.currentlyInServer, true)); + return nonBotMembers; + }); + } catch (error) { + return handleDbError('Failed to get all members', error as Error); + } +} + +/** + * Set or update multiple members at once + * @param nonBotMembers - Array of member objects + */ +export async function setMembers( + nonBotMembers: Collection, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot set members'); + } + + await Promise.all( + nonBotMembers.map(async (member) => { + const memberInfo = await db + .select() + .from(schema.memberTable) + .where(eq(schema.memberTable.discordId, member.user.id)); + + if (memberInfo.length > 0) { + await updateMember({ + discordId: member.user.id, + discordUsername: member.user.username, + currentlyInServer: true, + }); + } else { + const members: typeof schema.memberTable.$inferInsert = { + discordId: member.user.id, + discordUsername: member.user.username, + }; + await db.insert(schema.memberTable).values(members); + } + }), + ); + } catch (error) { + handleDbError('Failed to set members', error as Error); + } +} + +/** + * Get detailed information about a specific member including moderation history + * @param discordId - Discord ID of the user + * @returns Member object with moderation history + */ +export async function getMember( + discordId: string, +): Promise< + | (schema.memberTableTypes & { moderations: schema.moderationTableTypes[] }) + | undefined +> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get member'); + } + + const cacheKey = `${discordId}-memberInfo`; + + const member = await withCache( + cacheKey, + async () => { + const memberData = await db + .select() + .from(schema.memberTable) + .where(eq(schema.memberTable.discordId, discordId)) + .then((rows) => rows[0]); + + return memberData as schema.memberTableTypes; + }, + ); + + const moderations = await getMemberModerationHistory(discordId); + + return { + ...member, + moderations, + }; + } catch (error) { + return handleDbError('Failed to get member', error as Error); + } +} + +/** + * Update a member's information in the database + * @param discordId - Discord ID of the user + * @param discordUsername - New username of the member + * @param currentlyInServer - Whether the member is currently in the server + * @param currentlyBanned - Whether the member is currently banned + */ +export async function updateMember({ + discordId, + discordUsername, + currentlyInServer, + currentlyBanned, +}: schema.memberTableTypes): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot update member'); + } + + await db + .update(schema.memberTable) + .set({ + discordUsername, + currentlyInServer, + currentlyBanned, + }) + .where(eq(schema.memberTable.discordId, discordId)); + + await Promise.all([ + invalidateCache(`${discordId}-memberInfo`), + invalidateCache('nonBotMembers'), + ]); + } catch (error) { + handleDbError('Failed to update member', error as Error); + } +} diff --git a/src/db/functions/moderationFunctions.ts b/src/db/functions/moderationFunctions.ts new file mode 100644 index 0000000..601a9ad --- /dev/null +++ b/src/db/functions/moderationFunctions.ts @@ -0,0 +1,96 @@ +import { eq } from 'drizzle-orm'; + +import { + db, + ensureDbInitialized, + handleDbError, + invalidateCache, + withCache, +} from '../db.js'; +import * as schema from '../schema.js'; + +/** + * Add a new moderation action to a member's history + * @param discordId - Discord ID of the user + * @param moderatorDiscordId - Discord ID of the moderator + * @param action - Type of action taken + * @param reason - Reason for the action + * @param duration - Duration of the action + * @param createdAt - Timestamp of when the action was taken + * @param expiresAt - Timestamp of when the action expires + * @param active - Wether the action is active or not + */ +export async function updateMemberModerationHistory({ + discordId, + moderatorDiscordId, + action, + reason, + duration, + createdAt, + expiresAt, + active, +}: schema.moderationTableTypes): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, update member moderation history', + ); + } + + const moderationEntry = { + discordId, + moderatorDiscordId, + action, + reason, + duration, + createdAt, + expiresAt, + active, + }; + + await db.insert(schema.moderationTable).values(moderationEntry); + + await Promise.all([ + invalidateCache(`${discordId}-moderationHistory`), + invalidateCache(`${discordId}-memberInfo`), + ]); + } catch (error) { + handleDbError('Failed to update moderation history', error as Error); + } +} + +/** + * Get a member's moderation history + * @param discordId - Discord ID of the user + * @returns Array of moderation actions + */ +export async function getMemberModerationHistory( + discordId: string, +): Promise { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot get member moderation history', + ); + } + + const cacheKey = `${discordId}-moderationHistory`; + + try { + return await withCache( + cacheKey, + async () => { + const history = await db + .select() + .from(schema.moderationTable) + .where(eq(schema.moderationTable.discordId, discordId)); + return history as schema.moderationTableTypes[]; + }, + ); + } catch (error) { + return handleDbError('Failed to get moderation history', error as Error); + } +}