From 890ca26c78b7350ee6dbe37f196952385da7d8ed Mon Sep 17 00:00:00 2001
From: Ahmad <103906421+ahmadk953@users.noreply.github.com>
Date: Sun, 16 Mar 2025 20:31:43 -0400
Subject: [PATCH] Added code coments, refactored db.ts and redis.ts, and added
 two new commands

---
 .github/workflows/npm-build-and-compile.yml |    2 +-
 README.md                                   |    6 +-
 config.example.json                         |   17 +-
 package.json                                |    4 +-
 src/commands/util/members.ts                |    2 +-
 src/commands/util/reconnect.ts              |  203 ++++
 src/commands/util/restart.ts                |   93 ++
 src/db/db.ts                                | 1010 +++++++++++++------
 src/db/redis.ts                             |  307 +++++-
 src/db/schema.ts                            |    1 +
 src/events/interactionCreate.ts             |    4 +-
 src/events/memberEvents.ts                  |   17 +-
 src/events/messageEvents.ts                 |    2 +-
 src/events/ready.ts                         |   16 +-
 src/structures/ExtendedClient.ts            |    3 +
 src/types/CommandTypes.ts                   |    9 +
 src/types/ConfigTypes.ts                    |   20 +-
 src/types/EventTypes.ts                     |    3 +
 src/util/configLoader.ts                    |    4 +
 src/util/countingManager.ts                 |   34 +
 src/util/deployCommand.ts                   |   29 +-
 src/util/eventLoader.ts                     |    4 +
 src/util/factManager.ts                     |   24 +-
 src/util/helpers.ts                         |  137 ++-
 src/util/levelingSystem.ts                  |  134 ++-
 src/util/logging/constants.ts               |    9 +
 src/util/logging/logAction.ts               |    9 +-
 src/util/logging/types.ts                   |   51 +
 src/util/logging/utils.ts                   |   56 +
 src/util/notificationHandler.ts             |  151 +++
 30 files changed, 1899 insertions(+), 462 deletions(-)
 create mode 100644 src/commands/util/reconnect.ts
 create mode 100644 src/commands/util/restart.ts
 create mode 100644 src/util/notificationHandler.ts

diff --git a/.github/workflows/npm-build-and-compile.yml b/.github/workflows/npm-build-and-compile.yml
index d22aa36..51c4c1a 100644
--- a/.github/workflows/npm-build-and-compile.yml
+++ b/.github/workflows/npm-build-and-compile.yml
@@ -12,7 +12,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [21.x]
+        node-version: [23.x]
 
     steps:
       - uses: actions/checkout@v4
diff --git a/README.md b/README.md
index 24e74a7..b556733 100644
--- a/README.md
+++ b/README.md
@@ -20,4 +20,8 @@ Compile: ``yarn compile``
 
 Start: ``yarn target``
 
-Build & Start: ``yarn start``
\ No newline at end of file
+Build & Start (dev): ``yarn start:dev``
+
+Build & Start (prod): ``yarn start:prod``
+
+Restart (works only when the bot is started with ``yarn start:prod``): ``yarn restart``
diff --git a/config.example.json b/config.example.json
index cecb413..32d9014 100644
--- a/config.example.json
+++ b/config.example.json
@@ -2,8 +2,16 @@
   "token": "DISCORD_BOT_API_KEY",
   "clientId": "DISCORD_BOT_ID",
   "guildId": "DISCORD_SERVER_ID",
-  "dbConnectionString": "POSTGRESQL_CONNECTION_STRING",
-  "redisConnectionString": "REDIS_CONNECTION_STRING",
+  "database": {
+    "dbConnectionString": "POSTGRESQL_CONNECTION_STRING",
+    "maxRetryAttempts": "MAX_RETRY_ATTEMPTS",
+    "retryDelay": "RETRY_DELAY_IN_MS"
+  },
+  "redis": {
+    "redisConnectionString": "REDIS_CONNECTION_STRING",
+    "retryAttempts": "RETRY_ATTEMPTS",
+    "initialRetryDelay": "INITIAL_RETRY_DELAY_IN_MS"
+  },
   "channels": {
     "welcome": "WELCOME_CHANNEL_ID",
     "logs": "LOG_CHANNEL_ID",
@@ -45,5 +53,10 @@
       }
     ],
     "factPingRole": "FACT_OF_THE_DAY_ROLE_ID"
+  },
+  "leveling": {
+    "xpCooldown": "XP_COOLDOWN_IN_SECONDS",
+    "minXpAwarded": "MINIMUM_XP_AWARDED",
+    "maxXpAwarded": "MAXIMUM_XP_AWARDED"
   }
 }
diff --git a/package.json b/package.json
index 0ef760c..e7a152a 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,9 @@
   "scripts": {
     "compile": "npx tsc",
     "target": "node ./target/discord-bot.js",
-    "start": "yarn run compile && yarn run target",
+    "start:dev": "yarn run compile && yarn run target",
+    "start:prod": "yarn compile && pm2 start ./target/discord-bot.js --name poixpixel-discord-bot",
+    "restart": "pm2 restart poixpixel-discord-bot",
     "lint": "npx eslint ./src && npx tsc --noEmit",
     "format": "prettier --check --ignore-path .prettierignore .",
     "format:fix": "prettier --write --ignore-path .prettierignore ."
diff --git a/src/commands/util/members.ts b/src/commands/util/members.ts
index 083ee64..e1bc945 100644
--- a/src/commands/util/members.ts
+++ b/src/commands/util/members.ts
@@ -19,7 +19,7 @@ const command: Command = {
   execute: async (interaction) => {
     let members = await getAllMembers();
     members = members.sort((a, b) =>
-      a.discordUsername.localeCompare(b.discordUsername),
+      (a.discordUsername ?? '').localeCompare(b.discordUsername ?? ''),
     );
 
     const ITEMS_PER_PAGE = 15;
diff --git a/src/commands/util/reconnect.ts b/src/commands/util/reconnect.ts
new file mode 100644
index 0000000..a4ed8f0
--- /dev/null
+++ b/src/commands/util/reconnect.ts
@@ -0,0 +1,203 @@
+import {
+  CommandInteraction,
+  PermissionsBitField,
+  SlashCommandBuilder,
+} from 'discord.js';
+
+import { SubcommandCommand } from '../../types/CommandTypes.js';
+import { loadConfig } from '../../util/configLoader.js';
+import {
+  initializeDatabaseConnection,
+  ensureDbInitialized,
+} from '../../db/db.js';
+import { isRedisConnected } from '../../db/redis.js';
+import {
+  NotificationType,
+  notifyManagers,
+} from '../../util/notificationHandler.js';
+
+const command: SubcommandCommand = {
+  data: new SlashCommandBuilder()
+    .setName('reconnect')
+    .setDescription('(Manager Only) Force reconnection to database or Redis')
+    .addSubcommand((subcommand) =>
+      subcommand
+        .setName('database')
+        .setDescription('(Manager Only) Force reconnection to the database'),
+    )
+    .addSubcommand((subcommand) =>
+      subcommand
+        .setName('redis')
+        .setDescription('(Manager Only) Force reconnection to Redis cache'),
+    )
+    .addSubcommand((subcommand) =>
+      subcommand
+        .setName('status')
+        .setDescription(
+          '(Manager Only) Check connection status of database and Redis',
+        ),
+    ),
+
+  execute: async (interaction) => {
+    if (!interaction.isChatInputCommand()) return;
+
+    const config = loadConfig();
+    const managerRoleId = config.roles.staffRoles.find(
+      (role) => role.name === 'Manager',
+    )?.roleId;
+
+    const member = await interaction.guild?.members.fetch(interaction.user.id);
+    const hasManagerRole = member?.roles.cache.has(managerRoleId || '');
+
+    if (
+      !hasManagerRole &&
+      !interaction.memberPermissions?.has(
+        PermissionsBitField.Flags.Administrator,
+      )
+    ) {
+      await interaction.reply({
+        content:
+          'You do not have permission to use this command. This command is restricted to users with the Manager role.',
+        flags: ['Ephemeral'],
+      });
+      return;
+    }
+
+    const subcommand = interaction.options.getSubcommand();
+
+    await interaction.deferReply({ flags: ['Ephemeral'] });
+
+    try {
+      if (subcommand === 'database') {
+        await handleDatabaseReconnect(interaction);
+      } else if (subcommand === 'redis') {
+        await handleRedisReconnect(interaction);
+      } else if (subcommand === 'status') {
+        await handleStatusCheck(interaction);
+      }
+    } catch (error) {
+      console.error(`Error in reconnect command (${subcommand}):`, error);
+      await interaction.editReply({
+        content: `An error occurred while processing the reconnect command: \`${error}\``,
+      });
+    }
+  },
+};
+
+/**
+ * Handle database reconnection
+ */
+async function handleDatabaseReconnect(interaction: CommandInteraction) {
+  await interaction.editReply('Attempting to reconnect to the database...');
+
+  try {
+    const success = await initializeDatabaseConnection();
+
+    if (success) {
+      await interaction.editReply(
+        '✅ **Database reconnection successful!** All database functions should now be operational.',
+      );
+
+      notifyManagers(
+        interaction.client,
+        NotificationType.DATABASE_CONNECTION_RESTORED,
+        `Database connection manually restored by ${interaction.user.tag}`,
+      );
+    } else {
+      await interaction.editReply(
+        '❌ **Database reconnection failed.** Check the logs for more details.',
+      );
+    }
+  } catch (error) {
+    console.error('Error reconnecting to database:', error);
+    await interaction.editReply(
+      `❌ **Database reconnection failed with error:** \`${error}\``,
+    );
+  }
+}
+
+/**
+ * Handle Redis reconnection
+ */
+async function handleRedisReconnect(interaction: CommandInteraction) {
+  await interaction.editReply('Attempting to reconnect to Redis...');
+
+  try {
+    const redisModule = await import('../../db/redis.js');
+
+    await redisModule.ensureRedisConnection();
+
+    const isConnected = redisModule.isRedisConnected();
+
+    if (isConnected) {
+      await interaction.editReply(
+        '✅ **Redis reconnection successful!** Cache functionality is now available.',
+      );
+
+      notifyManagers(
+        interaction.client,
+        NotificationType.REDIS_CONNECTION_RESTORED,
+        `Redis connection manually restored by ${interaction.user.tag}`,
+      );
+    } else {
+      await interaction.editReply(
+        '❌ **Redis reconnection failed.** The bot will continue to function without caching capabilities.',
+      );
+    }
+  } catch (error) {
+    console.error('Error reconnecting to Redis:', error);
+    await interaction.editReply(
+      `❌ **Redis reconnection failed with error:** \`${error}\``,
+    );
+  }
+}
+
+/**
+ * Handle status check for both services
+ */
+async function handleStatusCheck(interaction: any) {
+  await interaction.editReply('Checking connection status...');
+
+  try {
+    const dbStatus = await (async () => {
+      try {
+        await ensureDbInitialized();
+        return true;
+      } catch {
+        return false;
+      }
+    })();
+
+    const redisStatus = isRedisConnected();
+
+    const statusEmbed = {
+      title: '🔌 Service Connection Status',
+      fields: [
+        {
+          name: 'Database',
+          value: dbStatus ? '✅ Connected' : '❌ Disconnected',
+          inline: true,
+        },
+        {
+          name: 'Redis Cache',
+          value: redisStatus
+            ? '✅ Connected'
+            : '⚠️ Disconnected (caching disabled)',
+          inline: true,
+        },
+      ],
+      color:
+        dbStatus && redisStatus ? 0x00ff00 : dbStatus ? 0xffaa00 : 0xff0000,
+      timestamp: new Date().toISOString(),
+    };
+
+    await interaction.editReply({ content: '', embeds: [statusEmbed] });
+  } catch (error) {
+    console.error('Error checking connection status:', error);
+    await interaction.editReply(
+      `❌ **Error checking connection status:** \`${error}\``,
+    );
+  }
+}
+
+export default command;
diff --git a/src/commands/util/restart.ts b/src/commands/util/restart.ts
new file mode 100644
index 0000000..bf7afb4
--- /dev/null
+++ b/src/commands/util/restart.ts
@@ -0,0 +1,93 @@
+import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+
+import { Command } from '../../types/CommandTypes.js';
+import { loadConfig } from '../../util/configLoader.js';
+import {
+  NotificationType,
+  notifyManagers,
+} from '../../util/notificationHandler.js';
+import { isRedisConnected } from '../../db/redis.js';
+import { ensureDatabaseConnection } from '../../db/db.js';
+
+const execAsync = promisify(exec);
+
+const command: Command = {
+  data: new SlashCommandBuilder()
+    .setName('restart')
+    .setDescription('(Manager Only) Restart the bot'),
+  execute: async (interaction) => {
+    const config = loadConfig();
+    const managerRoleId = config.roles.staffRoles.find(
+      (role) => role.name === 'Manager',
+    )?.roleId;
+
+    const member = await interaction.guild?.members.fetch(interaction.user.id);
+    const hasManagerRole = member?.roles.cache.has(managerRoleId || '');
+
+    if (
+      !hasManagerRole &&
+      !interaction.memberPermissions?.has(
+        PermissionsBitField.Flags.Administrator,
+      )
+    ) {
+      await interaction.reply({
+        content:
+          'You do not have permission to restart the bot. This command is restricted to users with the Manager role.',
+        flags: ['Ephemeral'],
+      });
+      return;
+    }
+
+    await interaction.reply({
+      content: 'Restarting the bot... This may take a few moments.',
+      flags: ['Ephemeral'],
+    });
+
+    const dbConnected = await ensureDatabaseConnection();
+    const redisConnected = isRedisConnected();
+    let statusInfo = '';
+
+    if (!dbConnected) {
+      statusInfo += '⚠️ Database is currently disconnected\n';
+    }
+
+    if (!redisConnected) {
+      statusInfo += '⚠️ Redis caching is currently unavailable\n';
+    }
+
+    if (dbConnected && redisConnected) {
+      statusInfo = '✅ All services are operational\n';
+    }
+
+    await notifyManagers(
+      interaction.client,
+      NotificationType.BOT_RESTARTING,
+      `Restart initiated by ${interaction.user.tag}\n\nCurrent service status:\n${statusInfo}`,
+    );
+
+    setTimeout(async () => {
+      try {
+        console.log(
+          `Bot restart initiated by ${interaction.user.tag} (${interaction.user.id})`,
+        );
+
+        await execAsync('yarn restart');
+      } catch (error) {
+        console.error('Failed to restart the bot:', error);
+        try {
+          await interaction.followUp({
+            content:
+              'Failed to restart the bot. Check the console for details.',
+            flags: ['Ephemeral'],
+          });
+        } catch {
+          // If this fails too, we can't do much
+        }
+      }
+    }, 1000);
+  },
+};
+
+export default command;
diff --git a/src/db/db.ts b/src/db/db.ts
index b47d689..23e57f2 100644
--- a/src/db/db.ts
+++ b/src/db/db.ts
@@ -1,21 +1,34 @@
 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 * 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 {
+  logManagerNotification,
+  NotificationType,
+  notifyManagers,
+} from '../util/notificationHandler.js';
 
 const { Pool } = pkg;
 const config = loadConfig();
 
-const dbPool = new Pool({
-  connectionString: config.dbConnectionString,
-  ssl: true,
-});
-export const db = drizzle({ client: dbPool, schema });
+// Database connection state
+let isDbConnected = false;
+let connectionAttempts = 0;
+const MAX_DB_RETRY_ATTEMPTS = config.database.maxRetryAttempts;
+const INITIAL_DB_RETRY_DELAY = config.database.retryDelay;
+let hasNotifiedDbDisconnect = false;
+let discordClient: Client | null = null;
+let dbPool: pkg.Pool;
+export let db: ReturnType<typeof drizzle>;
 
+/**
+ * Custom error class for database errors
+ */
 class DatabaseError extends Error {
   constructor(
     message: string,
@@ -26,121 +39,358 @@ class DatabaseError extends Error {
   }
 }
 
+/**
+ * Sets the Discord client for sending notifications
+ * @param client - The Discord client
+ */
+export function setDiscordClient(client: Client): void {
+  discordClient = client;
+}
+
+/**
+ * Initializes the database connection with retry logic
+ */
+export async function initializeDatabaseConnection(): Promise<boolean> {
+  try {
+    if (dbPool) {
+      try {
+        await dbPool.query('SELECT 1');
+        isDbConnected = true;
+        return true;
+      } catch (error) {
+        console.warn(
+          'Existing database connection is not responsive, creating a new one',
+        );
+        try {
+          await dbPool.end();
+        } catch (endError) {
+          console.error('Error ending pool:', endError);
+        }
+      }
+    }
+
+    // Log the database connection string (without sensitive info)
+    console.log(
+      `Connecting to database... (connectionString length: ${config.database.dbConnectionString.length})`,
+    );
+
+    dbPool = new Pool({
+      connectionString: config.database.dbConnectionString,
+      ssl: true,
+      connectionTimeoutMillis: 10000,
+    });
+
+    await dbPool.query('SELECT 1');
+
+    db = drizzle({ client: dbPool, schema });
+
+    console.info('Successfully connected to database');
+    isDbConnected = true;
+    connectionAttempts = 0;
+
+    if (hasNotifiedDbDisconnect && discordClient) {
+      logManagerNotification(NotificationType.DATABASE_CONNECTION_RESTORED);
+      notifyManagers(
+        discordClient,
+        NotificationType.DATABASE_CONNECTION_RESTORED,
+      );
+      hasNotifiedDbDisconnect = false;
+    }
+
+    return true;
+  } catch (error) {
+    console.error('Failed to connect to database:', error);
+    isDbConnected = false;
+    connectionAttempts++;
+
+    if (connectionAttempts >= MAX_DB_RETRY_ATTEMPTS) {
+      if (!hasNotifiedDbDisconnect && discordClient) {
+        const message = `Failed to connect to database after ${connectionAttempts} attempts.`;
+        console.error(message);
+        logManagerNotification(
+          NotificationType.DATABASE_CONNECTION_LOST,
+          `Error: ${error}`,
+        );
+        notifyManagers(
+          discordClient,
+          NotificationType.DATABASE_CONNECTION_LOST,
+          `Connection attempts exhausted after ${connectionAttempts} tries. The bot cannot function without database access and will now terminate.`,
+        );
+        hasNotifiedDbDisconnect = true;
+      }
+
+      setTimeout(() => {
+        console.error('Database connection failed, shutting down bot');
+        process.exit(1);
+      }, 3000);
+
+      return false;
+    }
+
+    // Try to reconnect after delay with exponential backoff
+    const delay = Math.min(
+      INITIAL_DB_RETRY_DELAY * Math.pow(2, connectionAttempts - 1),
+      30000,
+    );
+    console.log(
+      `Retrying database connection in ${delay}ms... (Attempt ${connectionAttempts}/${MAX_DB_RETRY_ATTEMPTS})`,
+    );
+
+    setTimeout(initializeDatabaseConnection, delay);
+
+    return false;
+  }
+}
+
+// Replace existing initialization with a properly awaited one
+let dbInitPromise = initializeDatabaseConnection().catch((error) => {
+  console.error('Failed to initialize database connection:', error);
+  process.exit(1);
+});
+
+/**
+ * Ensures the database is initialized and returns a promise
+ * @returns Promise for database initialization
+ */
+export async function ensureDbInitialized(): Promise<void> {
+  await dbInitPromise;
+
+  if (!isDbConnected) {
+    dbInitPromise = initializeDatabaseConnection();
+    await dbInitPromise;
+  }
+}
+
+/**
+ * Checks if the database connection is active and working
+ * @returns Promise resolving to true if connected, false otherwise
+ */
+export async function ensureDatabaseConnection(): Promise<boolean> {
+  await ensureDbInitialized();
+
+  if (!isDbConnected) {
+    return await initializeDatabaseConnection();
+  }
+
+  try {
+    await dbPool.query('SELECT 1');
+    return true;
+  } catch (error) {
+    console.error('Database connection test failed:', error);
+    isDbConnected = false;
+    return await initializeDatabaseConnection();
+  }
+}
+
+// ========================
+// Helper functions
+// ========================
+
+/**
+ * Generic error handler for database operations
+ * @param errorMessage - Error message to log
+ * @param error - Original error object
+ */
+export const handleDbError = (errorMessage: string, error: Error): never => {
+  console.error(`${errorMessage}: `, error);
+
+  if (
+    error.message.includes('connection') ||
+    error.message.includes('connect')
+  ) {
+    isDbConnected = false;
+    ensureDatabaseConnection().catch((err) => {
+      console.error('Failed to reconnect to database:', err);
+    });
+  }
+
+  throw new DatabaseError(errorMessage, error);
+};
+
+/**
+ * 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
+ */
+async function withCache<T>(
+  cacheKey: string,
+  dbFetch: () => Promise<T>,
+  ttl?: number,
+): Promise<T> {
+  try {
+    const cachedData = await getJson<T>(cacheKey);
+    if (cachedData !== null) {
+      return cachedData;
+    }
+  } catch (error) {
+    console.warn(
+      `Cache retrieval failed for ${cacheKey}, falling back to database:`,
+      error,
+    );
+  }
+
+  const data = await dbFetch();
+
+  try {
+    await setJson(cacheKey, data, ttl);
+  } catch (error) {
+    console.warn(`Failed to cache data for ${cacheKey}:`, error);
+  }
+
+  return data;
+}
+
+/**
+ * Invalidates a cache key if it exists
+ * @param cacheKey - Key to invalidate
+ */
+async function invalidateCache(cacheKey: string): Promise<void> {
+  try {
+    if (await exists(cacheKey)) {
+      await del(cacheKey);
+    }
+  } catch (error) {
+    console.warn(`Error invalidating cache for key ${cacheKey}:`, error);
+  }
+}
+
+// ========================
+// Member Functions
+// ========================
+
+/**
+ * Get all non-bot members currently in the server
+ * @returns Array of member objects
+ */
 export async function getAllMembers() {
   try {
-    if (await exists('nonBotMembers')) {
-      const memberData =
-        await getJson<(typeof schema.memberTable.$inferSelect)[]>(
-          'nonBotMembers',
-        );
-      if (memberData && memberData.length > 0) {
-        return memberData;
-      } else {
-        await del('nonBotMembers');
-        return await getAllMembers();
-      }
-    } else {
+    await ensureDbInitialized();
+
+    if (!db) {
+      console.error('Database not initialized, cannot get members');
+    }
+
+    const cacheKey = 'nonBotMembers';
+    return await withCache<schema.memberTableTypes[]>(cacheKey, async () => {
       const nonBotMembers = await db
         .select()
         .from(schema.memberTable)
         .where(eq(schema.memberTable.currentlyInServer, true));
-      await setJson<(typeof schema.memberTable.$inferSelect)[]>(
-        'nonBotMembers',
-        nonBotMembers,
-      );
       return nonBotMembers;
-    }
-  } catch (error) {
-    console.error('Error getting all members: ', error);
-    throw new DatabaseError('Failed to get all members: ', error as Error);
-  }
-}
-
-export async function setMembers(nonBotMembers: any) {
-  try {
-    nonBotMembers.forEach(async (member: any) => {
-      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) {
-    console.error('Error setting members: ', error);
-    throw new DatabaseError('Failed to set members: ', error as Error);
+    return handleDbError('Failed to get all members', error as Error);
   }
 }
 
-export async function getMember(discordId: string) {
+/**
+ * Set or update multiple members at once
+ * @param nonBotMembers - Array of member objects
+ */
+export async function setMembers(
+  nonBotMembers: Collection<string, GuildMember>,
+): Promise<void> {
   try {
-    if (await exists(`${discordId}-memberInfo`)) {
-      const cachedMember = await getJson<
-        typeof schema.memberTable.$inferSelect
-      >(`${discordId}-memberInfo`);
-      const cachedModerationHistory = await getJson<
-        (typeof schema.moderationTable.$inferSelect)[]
-      >(`${discordId}-moderationHistory`);
+    await ensureDbInitialized();
 
-      if (
-        cachedMember &&
-        'discordId' in cachedMember &&
-        cachedModerationHistory &&
-        cachedModerationHistory.length > 0
-      ) {
-        return {
-          ...cachedMember,
-          moderations: cachedModerationHistory,
-        };
-      } else {
-        await del(`${discordId}-memberInfo`);
-        await del(`${discordId}-moderationHistory`);
-        return await getMember(discordId);
-      }
-    } else {
-      const member = await db.query.memberTable.findFirst({
-        where: eq(schema.memberTable.discordId, discordId),
-        with: {
-          moderations: true,
-        },
-      });
-
-      await setJson<typeof schema.memberTable.$inferSelect>(
-        `${discordId}-memberInfo`,
-        member!,
-      );
-      await setJson<(typeof schema.moderationTable.$inferSelect)[]>(
-        `${discordId}-moderationHistory`,
-        member!.moderations,
-      );
-
-      return member;
+    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) {
-    console.error('Error getting member: ', error);
-    throw new DatabaseError('Failed to get member: ', error as 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<schema.memberTableTypes>(
+      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) {
+}: schema.memberTableTypes): Promise<void> {
   try {
-    const result = await db
+    await ensureDbInitialized();
+
+    if (!db) {
+      console.error('Database not initialized, cannot update member');
+    }
+
+    await db
       .update(schema.memberTable)
       .set({
         discordUsername,
@@ -149,193 +399,252 @@ export async function updateMember({
       })
       .where(eq(schema.memberTable.discordId, discordId));
 
-    if (await exists(`${discordId}-memberInfo`)) {
-      await del(`${discordId}-memberInfo`);
-    }
-    if (await exists('nonBotMembers')) {
-      await del('nonBotMembers');
-    }
-
-    return result;
+    await Promise.all([
+      invalidateCache(`${discordId}-memberInfo`),
+      invalidateCache('nonBotMembers'),
+    ]);
   } catch (error) {
-    console.error('Error updating member: ', error);
-    throw new DatabaseError('Failed to update member: ', error as 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<schema.levelTableTypes> {
   try {
-    if (await exists(`level-${discordId}`)) {
-      const cachedLevel = await getJson<schema.levelTableTypes>(
-        `level-${discordId}`,
-      );
-      if (cachedLevel !== null) {
-        return cachedLevel;
+    await ensureDbInitialized();
+
+    if (!db) {
+      console.error('Database not initialized, cannot get user level');
+    }
+
+    const cacheKey = `level-${discordId}`;
+
+    return await withCache<schema.levelTableTypes>(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,
+        };
       }
-      await del(`level-${discordId}`);
-    }
 
-    const level = await db
-      .select()
-      .from(schema.levelTable)
-      .where(eq(schema.levelTable.discordId, discordId))
-      .then((rows) => rows[0]);
-
-    if (level) {
-      const typedLevel: schema.levelTableTypes = {
-        ...level,
-        lastMessageTimestamp: level.lastMessageTimestamp ?? undefined,
+      const newLevel: schema.levelTableTypes = {
+        discordId,
+        xp: 0,
+        level: 0,
+        lastMessageTimestamp: new Date(),
       };
-      await setJson(`level-${discordId}`, typedLevel);
-      return typedLevel;
-    }
 
-    const newLevel = {
-      discordId,
-      xp: 0,
-      level: 0,
-      lastMessageTimestamp: new Date(),
-    };
-
-    await db.insert(schema.levelTable).values(newLevel);
-    await setJson(`level-${discordId}`, newLevel);
-    return newLevel;
+      await db.insert(schema.levelTable).values(newLevel);
+      return newLevel;
+    });
   } catch (error) {
-    console.error('Error getting user level:', error);
-    throw error;
+    return handleDbError('Error getting user level', error as Error);
   }
 }
 
-export async function addXpToUser(discordId: string, amount: number) {
+/**
+ * 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;
+}> {
   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;
 
     userData.xp += amount;
     userData.lastMessageTimestamp = new Date();
-
-    const newLevel = calculateLevelFromXp(userData.xp);
-    userData.level = newLevel;
-
-    await db
-      .update(schema.levelTable)
-      .set({
-        xp: userData.xp,
-        level: newLevel,
-        lastMessageTimestamp: userData.lastMessageTimestamp,
-      })
-      .where(eq(schema.levelTable.discordId, discordId));
-
-    await setJson(`level-${discordId}`, userData);
+    userData.level = calculateLevelFromXp(userData.xp);
 
     await invalidateLeaderboardCache();
+    await invalidateCache(cacheKey);
+    await withCache<schema.levelTableTypes>(
+      cacheKey,
+      async () => {
+        const result = await db
+          .update(schema.levelTable)
+          .set({
+            xp: userData.xp,
+            level: userData.level,
+            lastMessageTimestamp: userData.lastMessageTimestamp,
+          })
+          .where(eq(schema.levelTable.discordId, discordId))
+          .returning();
+
+        return result[0] as schema.levelTableTypes;
+      },
+      300,
+    );
 
     return {
-      leveledUp: newLevel > currentLevel,
-      newLevel,
+      leveledUp: userData.level > currentLevel,
+      newLevel: userData.level,
       oldLevel: currentLevel,
     };
   } catch (error) {
-    console.error('Error adding XP to user:', error);
-    throw 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<number> {
   try {
-    if (await exists('xp-leaderboard-cache')) {
-      const leaderboardCache = await getJson<
-        Array<{ discordId: string; xp: number }>
-      >('xp-leaderboard-cache');
+    await ensureDbInitialized();
 
-      if (leaderboardCache) {
-        const userIndex = leaderboardCache.findIndex(
-          (member) => member.discordId === discordId,
-        );
+    if (!db) {
+      console.error('Database not initialized, cannot get user rank');
+    }
 
-        if (userIndex !== -1) {
-          return userIndex + 1;
-        }
+    const leaderboardCache = await getLeaderboardData();
+
+    if (leaderboardCache) {
+      const userIndex = leaderboardCache.findIndex(
+        (member) => member.discordId === discordId,
+      );
+
+      if (userIndex !== -1) {
+        return userIndex + 1;
       }
     }
 
-    const allMembers = await db
-      .select({
-        discordId: schema.levelTable.discordId,
-        xp: schema.levelTable.xp,
-      })
-      .from(schema.levelTable)
-      .orderBy(desc(schema.levelTable.xp));
-
-    await setJson('xp-leaderboard-cache', allMembers, 300);
-
-    const userIndex = allMembers.findIndex(
-      (member) => member.discordId === discordId,
-    );
-
-    return userIndex !== -1 ? userIndex + 1 : 1;
-  } catch (error) {
-    console.error('Error getting user rank:', error);
     return 1;
-  }
-}
-
-export async function invalidateLeaderboardCache() {
-  try {
-    if (await exists('xp-leaderboard-cache')) {
-      await del('xp-leaderboard-cache');
-    }
   } catch (error) {
-    console.error('Error invalidating leaderboard cache:', error);
+    return handleDbError('Failed to get user rank', error as Error);
   }
 }
 
-export async function getLevelLeaderboard(limit = 10) {
+/**
+ * Clear leaderboard cache
+ */
+export async function invalidateLeaderboardCache(): Promise<void> {
+  await invalidateCache('xp-leaderboard-cache');
+}
+
+/**
+ * Helper function to get or create leaderboard data
+ * @returns Array of leaderboard data
+ */
+async function getLeaderboardData(): Promise<
+  Array<{
+    discordId: string;
+    xp: number;
+  }>
+> {
   try {
-    if (await exists('xp-leaderboard-cache')) {
-      const leaderboardCache = await getJson<
-        Array<{ discordId: string; xp: number }>
-      >('xp-leaderboard-cache');
+    await ensureDbInitialized();
 
-      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;
-      }
+    if (!db) {
+      console.error('Database not initialized, cannot get leaderboard data');
     }
 
-    const leaderboard = await db
+    const cacheKey = 'xp-leaderboard-cache';
+    return withCache<Array<{ discordId: string; xp: number }>>(
+      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);
+  }
+}
+
+/**
+ * Get the XP leaderboard
+ * @param limit - Number of entries to return
+ * @returns Array of leaderboard entries
+ */
+export async function getLevelLeaderboard(
+  limit = 10,
+): Promise<schema.levelTableTypes[]> {
+  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);
-
-    const allMembers = await db
-      .select({
-        discordId: schema.levelTable.discordId,
-        xp: schema.levelTable.xp,
-      })
-      .from(schema.levelTable)
-      .orderBy(desc(schema.levelTable.xp));
-
-    await setJson('xp-leaderboard-cache', allMembers, 300);
-
-    return leaderboard;
+      .limit(limit)) as schema.levelTableTypes[];
   } catch (error) {
-    console.error('Error getting leaderboard:', error);
-    throw 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,
@@ -345,8 +654,16 @@ export async function updateMemberModerationHistory({
   createdAt,
   expiresAt,
   active,
-}: schema.moderationTableTypes) {
+}: schema.moderationTableTypes): Promise<void> {
   try {
+    await ensureDbInitialized();
+
+    if (!db) {
+      console.error(
+        'Database not initialized, update member moderation history',
+      );
+    }
+
     const moderationEntry = {
       discordId,
       moderatorDiscordId,
@@ -357,111 +674,138 @@ export async function updateMemberModerationHistory({
       expiresAt,
       active,
     };
-    const result = await db
-      .insert(schema.moderationTable)
-      .values(moderationEntry);
 
-    if (await exists(`${discordId}-moderationHistory`)) {
-      await del(`${discordId}-moderationHistory`);
-    }
-    if (await exists(`${discordId}-memberInfo`)) {
-      await del(`${discordId}-memberInfo`);
-    }
+    await db.insert(schema.moderationTable).values(moderationEntry);
 
-    return result;
+    await Promise.all([
+      invalidateCache(`${discordId}-moderationHistory`),
+      invalidateCache(`${discordId}-memberInfo`),
+    ]);
   } catch (error) {
-    console.error('Error updating moderation history: ', error);
-    throw new DatabaseError(
-      'Failed to update moderation history: ',
-      error as Error,
-    );
+    handleDbError('Failed to update moderation history', error as Error);
   }
 }
 
-export async function getMemberModerationHistory(discordId: string) {
+/**
+ * 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<schema.moderationTableTypes[]> {
+  await ensureDbInitialized();
+
+  if (!db) {
+    console.error(
+      'Database not initialized, cannot get member moderation history',
+    );
+  }
+
+  const cacheKey = `${discordId}-moderationHistory`;
+
   try {
-    if (await exists(`${discordId}-moderationHistory`)) {
-      return await getJson<(typeof schema.moderationTable.$inferSelect)[]>(
-        `${discordId}-moderationHistory`,
-      );
-    } else {
-      const moderationHistory = await db
-        .select()
-        .from(schema.moderationTable)
-        .where(eq(schema.moderationTable.discordId, discordId));
-
-      await setJson<(typeof schema.moderationTable.$inferSelect)[]>(
-        `${discordId}-moderationHistory`,
-        moderationHistory,
-      );
-      return moderationHistory;
-    }
-  } catch (error) {
-    console.error('Error getting moderation history: ', error);
-    throw new DatabaseError(
-      'Failed to get moderation history: ',
-      error as Error,
+    return await withCache<schema.moderationTableTypes[]>(
+      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) {
+}: schema.factTableTypes): Promise<void> {
   try {
-    const result = await db.insert(schema.factTable).values({
+    await ensureDbInitialized();
+
+    if (!db) {
+      console.error('Database not initialized, cannot add fact');
+    }
+
+    await db.insert(schema.factTable).values({
       content,
       source,
       addedBy,
       approved,
     });
 
-    await del('unusedFacts');
-    return result;
+    await invalidateCache('unused-facts');
   } catch (error) {
-    console.error('Error adding fact:', error);
-    throw new DatabaseError('Failed to add fact:', error as 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<number> {
   try {
+    await ensureDbInitialized();
+
+    if (!db) {
+      console.error('Database not initialized, cannot get last inserted fact');
+    }
+
     const result = await db
       .select({ id: sql<number>`MAX(${schema.factTable.id})` })
       .from(schema.factTable);
 
     return result[0]?.id ?? 0;
   } catch (error) {
-    console.error('Error getting last inserted fact ID:', error);
-    throw new DatabaseError(
-      'Failed to get last inserted fact ID:',
-      error as Error,
-    );
+    return handleDbError('Failed to get last inserted fact ID', error as Error);
   }
 }
 
-export async function getRandomUnusedFact() {
+/**
+ * Get a random fact that hasn't been used yet
+ * @returns Random fact object
+ */
+export async function getRandomUnusedFact(): Promise<schema.factTableTypes> {
   try {
-    if (await exists('unusedFacts')) {
-      const facts =
-        await getJson<(typeof schema.factTable.$inferSelect)[]>('unusedFacts');
-      if (facts && facts.length > 0) {
-        return facts[Math.floor(Math.random() * facts.length)];
-      }
+    await ensureDbInitialized();
+
+    if (!db) {
+      console.error('Database not initialized, cannot get random unused fact');
     }
 
-    const facts = await db
-      .select()
-      .from(schema.factTable)
-      .where(
-        and(
-          eq(schema.factTable.approved, true),
-          isNull(schema.factTable.usedOn),
-        ),
-      );
+    const cacheKey = 'unused-facts';
+    const facts = await withCache<schema.factTableTypes[]>(
+      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
@@ -469,67 +813,101 @@ export async function getRandomUnusedFact() {
         .set({ usedOn: null })
         .where(eq(schema.factTable.approved, true));
 
+      await invalidateCache(cacheKey);
       return await getRandomUnusedFact();
     }
 
-    await setJson<(typeof schema.factTable.$inferSelect)[]>(
-      'unusedFacts',
-      facts,
-    );
-    return facts[Math.floor(Math.random() * facts.length)];
+    return facts[
+      Math.floor(Math.random() * facts.length)
+    ] as schema.factTableTypes;
   } catch (error) {
-    console.error('Error getting random fact:', error);
-    throw new DatabaseError('Failed to get random fact:', error as Error);
+    return handleDbError('Failed to get random fact', error as Error);
   }
 }
 
-export async function markFactAsUsed(id: number) {
+/**
+ * Mark a fact as used
+ * @param id - ID of the fact to mark as used
+ */
+export async function markFactAsUsed(id: number): Promise<void> {
   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 del('unusedFacts');
+    await invalidateCache('unused-facts');
   } catch (error) {
-    console.error('Error marking fact as used:', error);
-    throw new DatabaseError('Failed to mark fact as used:', error as Error);
+    handleDbError('Failed to mark fact as used', error as Error);
   }
 }
 
-export async function getPendingFacts() {
+/**
+ * Get all pending facts that need approval
+ * @returns Array of pending fact objects
+ */
+export async function getPendingFacts(): Promise<schema.factTableTypes[]> {
   try {
-    return await db
+    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));
+      .where(eq(schema.factTable.approved, false))) as schema.factTableTypes[];
   } catch (error) {
-    console.error('Error getting pending facts:', error);
-    throw new DatabaseError('Failed to get pending facts:', error as Error);
+    return handleDbError('Failed to get pending facts', error as Error);
   }
 }
 
-export async function approveFact(id: number) {
+/**
+ * Approve a fact
+ * @param id - ID of the fact to approve
+ */
+export async function approveFact(id: number): Promise<void> {
   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 del('unusedFacts');
+    await invalidateCache('unused-facts');
   } catch (error) {
-    console.error('Error approving fact:', error);
-    throw new DatabaseError('Failed to approve fact:', error as Error);
+    handleDbError('Failed to approve fact', error as Error);
   }
 }
 
-export async function deleteFact(id: number) {
+/**
+ * Delete a fact
+ * @param id - ID of the fact to delete
+ */
+export async function deleteFact(id: number): Promise<void> {
   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 del('unusedFacts');
+    await invalidateCache('unused-facts');
   } catch (error) {
-    console.error('Error deleting fact:', error);
-    throw new DatabaseError('Failed to delete fact:', error as Error);
+    return handleDbError('Failed to delete fact', error as Error);
   }
 }
diff --git a/src/db/redis.ts b/src/db/redis.ts
index e513072..6b58f6b 100644
--- a/src/db/redis.ts
+++ b/src/db/redis.ts
@@ -1,9 +1,31 @@
 import Redis from 'ioredis';
+import { Client } from 'discord.js';
+
 import { loadConfig } from '../util/configLoader.js';
+import {
+  logManagerNotification,
+  NotificationType,
+  notifyManagers,
+} from '../util/notificationHandler.js';
 
 const config = loadConfig();
-const redis = new Redis(config.redisConnectionString);
 
+// Redis connection state
+let isRedisAvailable = false;
+let redis: Redis;
+let connectionAttempts = 0;
+const MAX_RETRY_ATTEMPTS = config.redis.retryAttempts;
+const INITIAL_RETRY_DELAY = config.redis.initialRetryDelay;
+let hasNotifiedDisconnect = false;
+let discordClient: Client | null = null;
+
+// ========================
+// Redis Utility Classes and Helper Functions
+// ========================
+
+/**
+ * Custom error class for Redis errors
+ */
 class RedisError extends Error {
   constructor(
     message: string,
@@ -14,77 +36,271 @@ class RedisError extends Error {
   }
 }
 
-redis.on('error', (error: Error) => {
-  console.error('Redis connection error:', error);
-  throw new RedisError('Failed to connect to Redis instance: ', error);
-});
+/**
+ * Redis error handler
+ * @param errorMessage - The error message to log
+ * @param error - The error object
+ */
+const handleRedisError = (errorMessage: string, error: Error): null => {
+  console.error(`${errorMessage}:`, error);
+  throw new RedisError(errorMessage, error);
+};
 
-redis.on('connect', () => {
-  console.log('Successfully connected to Redis');
-});
+/**
+ * Sets the Discord client for sending notifications
+ * @param client - The Discord client
+ */
+export function setDiscordClient(client: Client): void {
+  discordClient = client;
+}
 
+/**
+ * Initializes the Redis connection with retry logic
+ */
+async function initializeRedisConnection() {
+  try {
+    if (redis && redis.status !== 'end' && redis.status !== 'close') {
+      return;
+    }
+
+    redis = new Redis(config.redis.redisConnectionString, {
+      retryStrategy(times) {
+        connectionAttempts = times;
+        if (times >= MAX_RETRY_ATTEMPTS) {
+          const message = `Failed to connect to Redis after ${times} attempts. Caching will be disabled.`;
+          console.warn(message);
+
+          if (!hasNotifiedDisconnect && discordClient) {
+            logManagerNotification(NotificationType.REDIS_CONNECTION_LOST);
+            notifyManagers(
+              discordClient,
+              NotificationType.REDIS_CONNECTION_LOST,
+              `Connection attempts exhausted after ${times} tries. Caching is now disabled.`,
+            );
+            hasNotifiedDisconnect = true;
+          }
+
+          return null;
+        }
+
+        const delay = Math.min(INITIAL_RETRY_DELAY * Math.pow(2, times), 30000);
+        console.log(
+          `Retrying Redis connection in ${delay}ms... (Attempt ${times + 1}/${MAX_RETRY_ATTEMPTS})`,
+        );
+        return delay;
+      },
+      maxRetriesPerRequest: 3,
+      enableOfflineQueue: true,
+    });
+
+    // ========================
+    // Redis Events
+    // ========================
+    redis.on('error', (error: Error) => {
+      console.error('Redis Connection Error:', error);
+      isRedisAvailable = false;
+    });
+
+    redis.on('connect', () => {
+      console.info('Successfully connected to Redis');
+      isRedisAvailable = true;
+      connectionAttempts = 0;
+
+      if (hasNotifiedDisconnect && discordClient) {
+        logManagerNotification(NotificationType.REDIS_CONNECTION_RESTORED);
+        notifyManagers(
+          discordClient,
+          NotificationType.REDIS_CONNECTION_RESTORED,
+        );
+        hasNotifiedDisconnect = false;
+      }
+    });
+
+    redis.on('close', () => {
+      console.warn('Redis connection closed');
+      isRedisAvailable = false;
+
+      // Try to reconnect after some time if we've not exceeded max attempts
+      if (connectionAttempts < MAX_RETRY_ATTEMPTS) {
+        const delay = Math.min(
+          INITIAL_RETRY_DELAY * Math.pow(2, connectionAttempts),
+          30000,
+        );
+        setTimeout(initializeRedisConnection, delay);
+      } else if (!hasNotifiedDisconnect && discordClient) {
+        logManagerNotification(NotificationType.REDIS_CONNECTION_LOST);
+        notifyManagers(
+          discordClient,
+          NotificationType.REDIS_CONNECTION_LOST,
+          'Connection closed and max retry attempts reached.',
+        );
+        hasNotifiedDisconnect = true;
+      }
+    });
+
+    redis.on('reconnecting', () => {
+      console.info('Attempting to reconnect to Redis...');
+    });
+  } catch (error) {
+    console.error('Failed to initialize Redis:', error);
+    isRedisAvailable = false;
+
+    if (!hasNotifiedDisconnect && discordClient) {
+      logManagerNotification(
+        NotificationType.REDIS_CONNECTION_LOST,
+        `Error: ${error}`,
+      );
+      notifyManagers(
+        discordClient,
+        NotificationType.REDIS_CONNECTION_LOST,
+        `Initialization error: ${error}`,
+      );
+      hasNotifiedDisconnect = true;
+    }
+  }
+}
+
+// Initialize Redis connection
+initializeRedisConnection();
+
+/**
+ * Check if Redis is currently available, and attempt to reconnect if not
+ * @returns - True if Redis is connected and available
+ */
+export async function ensureRedisConnection(): Promise<boolean> {
+  if (!isRedisAvailable) {
+    await initializeRedisConnection();
+  }
+  return isRedisAvailable;
+}
+
+// ========================
+// Redis Functions
+// ========================
+
+/**
+ * Function to set a key in Redis
+ * @param key - The key to set
+ * @param value - The value to set
+ * @param ttl - The time to live for the key
+ * @returns - 'OK' if successful
+ */
 export async function set(
   key: string,
   value: string,
   ttl?: number,
-): Promise<'OK'> {
-  try {
-    await redis.set(key, value);
-    if (ttl) await redis.expire(key, ttl);
-  } catch (error) {
-    console.error('Redis set error: ', error);
-    throw new RedisError(`Failed to set key: ${key}, `, error as Error);
+): Promise<'OK' | null> {
+  if (!(await ensureRedisConnection())) {
+    console.warn('Redis unavailable, skipping set operation');
+    return null;
+  }
+
+  try {
+    await redis.set(`bot:${key}`, value);
+    if (ttl) await redis.expire(`bot:${key}`, ttl);
+    return 'OK';
+  } catch (error) {
+    return handleRedisError(`Failed to set key: ${key}`, error as Error);
   }
-  return Promise.resolve('OK');
 }
 
+/**
+ * Function to set a key in Redis with a JSON value
+ * @param key - The key to set
+ * @param value - The value to set
+ * @param ttl - The time to live for the key
+ * @returns - 'OK' if successful
+ */
 export async function setJson<T>(
   key: string,
   value: T,
   ttl?: number,
-): Promise<'OK'> {
+): Promise<'OK' | null> {
   return await set(key, JSON.stringify(value), ttl);
 }
 
-export async function incr(key: string): Promise<number> {
+/**
+ * Increments a key in Redis
+ * @param key - The key to increment
+ * @returns - The new value of the key, or null if Redis is unavailable
+ */
+export async function incr(key: string): Promise<number | null> {
+  if (!(await ensureRedisConnection())) {
+    console.warn('Redis unavailable, skipping increment operation');
+    return null;
+  }
+
   try {
-    return await redis.incr(key);
+    return await redis.incr(`bot:${key}`);
   } catch (error) {
-    console.error('Redis increment error: ', error);
-    throw new RedisError(`Failed to increment key: ${key}, `, error as Error);
+    return handleRedisError(`Failed to increment key: ${key}`, error as Error);
   }
 }
 
-export async function exists(key: string): Promise<boolean> {
+/**
+ * Checks if a key exists in Redis
+ * @param key - The key to check
+ * @returns - True if the key exists, false otherwise, or null if Redis is unavailable
+ */
+export async function exists(key: string): Promise<boolean | null> {
+  if (!(await ensureRedisConnection())) {
+    console.warn('Redis unavailable, skipping exists operation');
+    return null;
+  }
+
   try {
-    return (await redis.exists(key)) === 1;
+    return (await redis.exists(`bot:${key}`)) === 1;
   } catch (error) {
-    console.error('Redis exists error: ', error);
-    throw new RedisError(
-      `Failed to check if key exists: ${key}, `,
+    return handleRedisError(
+      `Failed to check if key exists: ${key}`,
       error as Error,
     );
   }
 }
 
+/**
+ * Gets the value of a key in Redis
+ * @param key - The key to get
+ * @returns - The value of the key, or null if the key does not exist or Redis is unavailable
+ */
 export async function get(key: string): Promise<string | null> {
+  if (!(await ensureRedisConnection())) {
+    console.warn('Redis unavailable, skipping get operation');
+    return null;
+  }
+
   try {
-    return await redis.get(key);
+    return await redis.get(`bot:${key}`);
   } catch (error) {
-    console.error('Redis get error: ', error);
-    throw new RedisError(`Failed to get key: ${key}, `, error as Error);
+    return handleRedisError(`Failed to get key: ${key}`, error as Error);
   }
 }
 
-export async function mget(...keys: string[]): Promise<(string | null)[]> {
+/**
+ * Gets the values of multiple keys in Redis
+ * @param keys - The keys to get
+ * @returns - The values of the keys, or null if Redis is unavailable
+ */
+export async function mget(
+  ...keys: string[]
+): Promise<(string | null)[] | null> {
+  if (!(await ensureRedisConnection())) {
+    console.warn('Redis unavailable, skipping mget operation');
+    return null;
+  }
+
   try {
-    return await redis.mget(keys);
+    return await redis.mget(...keys.map((key) => `bot:${key}`));
   } catch (error) {
-    console.error('Redis mget error: ', error);
-    throw new RedisError(`Failed to get keys: ${keys}, `, error as Error);
+    return handleRedisError('Failed to get keys', error as Error);
   }
 }
 
+/**
+ * Gets the value of a key in Redis and parses it as a JSON object
+ * @param key - The key to get
+ * @returns - The parsed JSON value of the key, or null if the key does not exist or Redis is unavailable
+ */
 export async function getJson<T>(key: string): Promise<T | null> {
   const value = await get(key);
   if (!value) return null;
@@ -95,11 +311,28 @@ export async function getJson<T>(key: string): Promise<T | null> {
   }
 }
 
-export async function del(key: string): Promise<number> {
+/**
+ * Deletes a key in Redis
+ * @param key - The key to delete
+ * @returns - The number of keys that were deleted, or null if Redis is unavailable
+ */
+export async function del(key: string): Promise<number | null> {
+  if (!(await ensureRedisConnection())) {
+    console.warn('Redis unavailable, skipping delete operation');
+    return null;
+  }
+
   try {
-    return await redis.del(key);
+    return await redis.del(`bot:${key}`);
   } catch (error) {
-    console.error('Redis del error: ', error);
-    throw new RedisError(`Failed to delete key: ${key}, `, error as Error);
+    return handleRedisError(`Failed to delete key: ${key}`, error as Error);
   }
 }
+
+/**
+ * Check if Redis is currently available
+ * @returns - True if Redis is connected and available
+ */
+export function isRedisConnected(): boolean {
+  return isRedisAvailable;
+}
diff --git a/src/db/schema.ts b/src/db/schema.ts
index fd6ddaa..61bdc94 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -75,6 +75,7 @@ export const memberRelations = relations(memberTable, ({ many, one }) => ({
     fields: [memberTable.discordId],
     references: [levelTable.discordId],
   }),
+  facts: many(factTable),
 }));
 
 export const levelRelations = relations(levelTable, ({ one }) => ({
diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts
index 10155dc..f25d2a3 100644
--- a/src/events/interactionCreate.ts
+++ b/src/events/interactionCreate.ts
@@ -63,7 +63,7 @@ export default {
         if (!interaction.memberPermissions?.has('ModerateMembers')) {
           await interaction.reply({
             content: 'You do not have permission to approve facts.',
-            ephemeral: true,
+            flags: ['Ephemeral'],
           });
           return;
         }
@@ -79,7 +79,7 @@ export default {
         if (!interaction.memberPermissions?.has('ModerateMembers')) {
           await interaction.reply({
             content: 'You do not have permission to reject facts.',
-            ephemeral: true,
+            flags: ['Ephemeral'],
           });
           return;
         }
diff --git a/src/events/memberEvents.ts b/src/events/memberEvents.ts
index a6ddc80..671f99a 100644
--- a/src/events/memberEvents.ts
+++ b/src/events/memberEvents.ts
@@ -1,4 +1,10 @@
-import { Events, Guild, GuildMember, PartialGuildMember } from 'discord.js';
+import {
+  Collection,
+  Events,
+  Guild,
+  GuildMember,
+  PartialGuildMember,
+} from 'discord.js';
 
 import { updateMember, setMembers } from '../db/db.js';
 import { generateMemberBanner } from '../util/helpers.js';
@@ -19,12 +25,9 @@ export const memberJoin: Event<typeof Events.GuildMemberAdd> = {
     }
 
     try {
-      await setMembers([
-        {
-          discordId: member.user.id,
-          discordUsername: member.user.username,
-        },
-      ]);
+      const memberCollection = new Collection<string, GuildMember>();
+      memberCollection.set(member.user.id, member);
+      await setMembers(memberCollection);
 
       if (!member.user.bot) {
         const attachment = await generateMemberBanner({
diff --git a/src/events/messageEvents.ts b/src/events/messageEvents.ts
index 24b7a66..07d0595 100644
--- a/src/events/messageEvents.ts
+++ b/src/events/messageEvents.ts
@@ -84,7 +84,7 @@ export const messageCreate: Event<typeof Events.MessageCreate> = {
         advancementsChannelId,
       );
 
-      if (!advancementsChannel || !advancementsChannel.isTextBased()) {
+      if (!advancementsChannel?.isTextBased()) {
         console.error(
           'Advancements channel not found or is not a text channel',
         );
diff --git a/src/events/ready.ts b/src/events/ready.ts
index b7c54ce..24c0ff7 100644
--- a/src/events/ready.ts
+++ b/src/events/ready.ts
@@ -1,16 +1,28 @@
 import { Client, Events } from 'discord.js';
 
-import { setMembers } from '../db/db.js';
+import { ensureDbInitialized, setMembers } from '../db/db.js';
 import { loadConfig } from '../util/configLoader.js';
 import { Event } from '../types/EventTypes.js';
 import { scheduleFactOfTheDay } from '../util/factManager.js';
 
+import {
+  ensureRedisConnection,
+  setDiscordClient as setRedisDiscordClient,
+} from '../db/redis.js';
+import { setDiscordClient as setDbDiscordClient } from '../db/db.js';
+
 export default {
   name: Events.ClientReady,
   once: true,
   execute: async (client: Client) => {
     const config = loadConfig();
     try {
+      setRedisDiscordClient(client);
+      setDbDiscordClient(client);
+
+      await ensureDbInitialized();
+      await ensureRedisConnection();
+
       const guild = client.guilds.cache.find(
         (guilds) => guilds.id === config.guildId,
       );
@@ -25,7 +37,7 @@ export default {
 
       await scheduleFactOfTheDay(client);
     } catch (error) {
-      console.error('Failed to initialize members in database:', error);
+      console.error('Failed to initialize the bot:', error);
     }
 
     console.log(`Ready! Logged in as ${client.user?.tag}`);
diff --git a/src/structures/ExtendedClient.ts b/src/structures/ExtendedClient.ts
index 4dfba26..bf0d153 100644
--- a/src/structures/ExtendedClient.ts
+++ b/src/structures/ExtendedClient.ts
@@ -4,6 +4,9 @@ import { Config } from '../types/ConfigTypes.js';
 import { deployCommands } from '../util/deployCommand.js';
 import { registerEvents } from '../util/eventLoader.js';
 
+/**
+ * Extended client class that extends the default Client class
+ */
 export class ExtendedClient extends Client {
   public commands: Collection<string, Command>;
   private config: Config;
diff --git a/src/types/CommandTypes.ts b/src/types/CommandTypes.ts
index 406f3f7..002cd17 100644
--- a/src/types/CommandTypes.ts
+++ b/src/types/CommandTypes.ts
@@ -5,16 +5,25 @@ import {
   SlashCommandSubcommandsOnlyBuilder,
 } from 'discord.js';
 
+/**
+ * Command interface for normal commands
+ */
 export interface Command {
   data: Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
   execute: (interaction: CommandInteraction) => Promise<void>;
 }
 
+/**
+ * Command interface for options commands
+ */
 export interface OptionsCommand {
   data: SlashCommandOptionsOnlyBuilder;
   execute: (interaction: CommandInteraction) => Promise<void>;
 }
 
+/**
+ * Command interface for subcommand commands
+ */
 export interface SubcommandCommand {
   data: SlashCommandSubcommandsOnlyBuilder;
   execute: (interaction: CommandInteraction) => Promise<void>;
diff --git a/src/types/ConfigTypes.ts b/src/types/ConfigTypes.ts
index 96ed2dd..b38b126 100644
--- a/src/types/ConfigTypes.ts
+++ b/src/types/ConfigTypes.ts
@@ -1,9 +1,20 @@
+/**
+ * Config interface for the bot
+ */
 export interface Config {
   token: string;
   clientId: string;
   guildId: string;
-  dbConnectionString: string;
-  redisConnectionString: string;
+  database: {
+    dbConnectionString: string;
+    maxRetryAttempts: number;
+    retryDelay: number;
+  };
+  redis: {
+    redisConnectionString: string;
+    retryAttempts: number;
+    initialRetryDelay: number;
+  };
   channels: {
     welcome: string;
     logs: string;
@@ -24,4 +35,9 @@ export interface Config {
     }[];
     factPingRole: string;
   };
+  leveling: {
+    xpCooldown: number;
+    minXpAwarded: number;
+    maxXpAwarded: number;
+  };
 }
diff --git a/src/types/EventTypes.ts b/src/types/EventTypes.ts
index f07556d..df6cf7c 100644
--- a/src/types/EventTypes.ts
+++ b/src/types/EventTypes.ts
@@ -1,5 +1,8 @@
 import { ClientEvents } from 'discord.js';
 
+/**
+ * Event interface for events
+ */
 export interface Event<K extends keyof ClientEvents> {
   name: K;
   once?: boolean;
diff --git a/src/util/configLoader.ts b/src/util/configLoader.ts
index 497e5a0..4a847dc 100644
--- a/src/util/configLoader.ts
+++ b/src/util/configLoader.ts
@@ -2,6 +2,10 @@ import { Config } from '../types/ConfigTypes.js';
 import fs from 'node:fs';
 import path from 'node:path';
 
+/**
+ * Loads the config file from the root directory
+ * @returns - The loaded config object
+ */
 export function loadConfig(): Config {
   try {
     const configPath = path.join(process.cwd(), './config.json');
diff --git a/src/util/countingManager.ts b/src/util/countingManager.ts
index f910690..a3beb67 100644
--- a/src/util/countingManager.ts
+++ b/src/util/countingManager.ts
@@ -16,6 +16,10 @@ const MILESTONE_REACTIONS = {
   multiples100: '🎉',
 };
 
+/**
+ * Initializes the counting data if it doesn't exist
+ * @returns - The initialized counting data
+ */
 export async function initializeCountingData(): Promise<CountingData> {
   const exists = await getJson<CountingData>('counting');
   if (exists) return exists;
@@ -31,6 +35,10 @@ export async function initializeCountingData(): Promise<CountingData> {
   return initialData;
 }
 
+/**
+ * Gets the current counting data
+ * @returns - The current counting data
+ */
 export async function getCountingData(): Promise<CountingData> {
   const data = await getJson<CountingData>('counting');
   if (!data) {
@@ -39,6 +47,10 @@ export async function getCountingData(): Promise<CountingData> {
   return data;
 }
 
+/**
+ * Updates the counting data with new data
+ * @param data - The data to update the counting data with
+ */
 export async function updateCountingData(
   data: Partial<CountingData>,
 ): Promise<void> {
@@ -47,6 +59,10 @@ export async function updateCountingData(
   await setJson<CountingData>('counting', updatedData);
 }
 
+/**
+ * Resets the counting data to the initial state
+ * @returns - The current count
+ */
 export async function resetCounting(): Promise<void> {
   await updateCountingData({
     currentCount: 0,
@@ -55,6 +71,11 @@ export async function resetCounting(): Promise<void> {
   return;
 }
 
+/**
+ * Processes a counting message to determine if it is valid
+ * @param message - The message to process
+ * @returns - An object with information about the message
+ */
 export async function processCountingMessage(message: Message): Promise<{
   isValid: boolean;
   expectedCount?: number;
@@ -125,6 +146,11 @@ export async function processCountingMessage(message: Message): Promise<{
   }
 }
 
+/**
+ * Adds counting reactions to a message based on the milestone type
+ * @param message - The message to add counting reactions to
+ * @param milestoneType - The type of milestone to add reactions for
+ */
 export async function addCountingReactions(
   message: Message,
   milestoneType: keyof typeof MILESTONE_REACTIONS,
@@ -140,11 +166,19 @@ export async function addCountingReactions(
   }
 }
 
+/**
+ * Gets the current counting status
+ * @returns - A string with the current counting status
+ */
 export async function getCountingStatus(): Promise<string> {
   const data = await getCountingData();
   return `Current count: ${data.currentCount}\nHighest count ever: ${data.highestCount}\nTotal correct counts: ${data.totalCorrect}`;
 }
 
+/**
+ * Sets the current count to a specific number
+ * @param count - The number to set as the current count
+ */
 export async function setCount(count: number): Promise<void> {
   if (!Number.isInteger(count) || count < 0) {
     throw new Error('Count must be a non-negative integer.');
diff --git a/src/util/deployCommand.ts b/src/util/deployCommand.ts
index 98b6934..891734b 100644
--- a/src/util/deployCommand.ts
+++ b/src/util/deployCommand.ts
@@ -11,6 +11,11 @@ const commandsPath = path.join(__dirname, 'target', 'commands');
 
 const rest = new REST({ version: '10' }).setToken(token);
 
+/**
+ * Gets all files in the command directory and its subdirectories
+ * @param directory - The directory to get files from
+ * @returns - An array of file paths
+ */
 const getFilesRecursively = (directory: string): string[] => {
   const files: string[] = [];
   const filesInDirectory = fs.readdirSync(directory);
@@ -30,15 +35,21 @@ const getFilesRecursively = (directory: string): string[] => {
 
 const commandFiles = getFilesRecursively(commandsPath);
 
+/**
+ * Registers all commands in the command directory with the Discord API
+ * @returns - An array of valid command objects
+ */
 export const deployCommands = async () => {
   try {
     console.log(
       `Started refreshing ${commandFiles.length} application (/) commands...`,
     );
 
-    const existingCommands = (await rest.get(
-      Routes.applicationGuildCommands(clientId, guildId),
-    )) as any[];
+    console.log('Undeploying all existing commands...');
+    await rest.put(Routes.applicationGuildCommands(clientId, guildId), {
+      body: [],
+    });
+    console.log('Successfully undeployed all commands');
 
     const commands = commandFiles.map(async (file) => {
       const commandModule = await import(`file://${file}`);
@@ -64,18 +75,6 @@ export const deployCommands = async () => {
 
     const apiCommands = validCommands.map((command) => command.data.toJSON());
 
-    const commandsToRemove = existingCommands.filter(
-      (existingCmd) =>
-        !apiCommands.some((newCmd) => newCmd.name === existingCmd.name),
-    );
-
-    for (const cmdToRemove of commandsToRemove) {
-      await rest.delete(
-        Routes.applicationGuildCommand(clientId, guildId, cmdToRemove.id),
-      );
-      console.log(`Removed command: ${cmdToRemove.name}`);
-    }
-
     const data: any = await rest.put(
       Routes.applicationGuildCommands(clientId, guildId),
       { body: apiCommands },
diff --git a/src/util/eventLoader.ts b/src/util/eventLoader.ts
index 855f296..d212380 100644
--- a/src/util/eventLoader.ts
+++ b/src/util/eventLoader.ts
@@ -7,6 +7,10 @@ import { dirname } from 'path';
 const __filename = fileURLToPath(import.meta.url);
 const __dirname = dirname(__filename);
 
+/**
+ * Registers all event handlers in the events directory
+ * @param client - The Discord client
+ */
 export async function registerEvents(client: Client): Promise<void> {
   try {
     const eventsPath = join(__dirname, '..', 'events');
diff --git a/src/util/factManager.ts b/src/util/factManager.ts
index 4663a18..d146f67 100644
--- a/src/util/factManager.ts
+++ b/src/util/factManager.ts
@@ -3,8 +3,22 @@ import { EmbedBuilder, Client } from 'discord.js';
 import { getRandomUnusedFact, markFactAsUsed } from '../db/db.js';
 import { loadConfig } from './configLoader.js';
 
-export async function scheduleFactOfTheDay(client: Client) {
+let isFactScheduled = false;
+
+/**
+ * Schedule the fact of the day to be posted daily
+ * @param client - The Discord client
+ */
+export async function scheduleFactOfTheDay(client: Client): Promise<void> {
+  if (isFactScheduled) {
+    console.log(
+      'Fact of the day already scheduled, skipping duplicate schedule',
+    );
+    return;
+  }
+
   try {
+    isFactScheduled = true;
     const now = new Date();
     const tomorrow = new Date();
     tomorrow.setDate(now.getDate() + 1);
@@ -14,6 +28,7 @@ export async function scheduleFactOfTheDay(client: Client) {
 
     setTimeout(() => {
       postFactOfTheDay(client);
+      isFactScheduled = false;
       scheduleFactOfTheDay(client);
     }, timeUntilMidnight);
 
@@ -22,11 +37,16 @@ export async function scheduleFactOfTheDay(client: Client) {
     );
   } catch (error) {
     console.error('Error scheduling fact of the day:', error);
+    isFactScheduled = false;
     setTimeout(() => scheduleFactOfTheDay(client), 60 * 60 * 1000);
   }
 }
 
-export async function postFactOfTheDay(client: Client) {
+/**
+ * Post the fact of the day to the configured channel
+ * @param client - The Discord client
+ */
+export async function postFactOfTheDay(client: Client): Promise<void> {
   try {
     const config = loadConfig();
     const guild = client.guilds.cache.get(config.guildId);
diff --git a/src/util/helpers.ts b/src/util/helpers.ts
index dcc6fca..edfe5aa 100644
--- a/src/util/helpers.ts
+++ b/src/util/helpers.ts
@@ -5,11 +5,16 @@ import { AttachmentBuilder, Client, GuildMember, Guild } from 'discord.js';
 import { and, eq } from 'drizzle-orm';
 
 import { moderationTable } from '../db/schema.js';
-import { db, updateMember } from '../db/db.js';
+import { db, handleDbError, updateMember } from '../db/db.js';
 import logAction from './logging/logAction.js';
 
 const __dirname = path.resolve();
 
+/**
+ * Turns a duration string into milliseconds
+ * @param duration - The duration to parse
+ * @returns - The parsed duration in milliseconds
+ */
 export function parseDuration(duration: string): number {
   const regex = /^(\d+)(s|m|h|d)$/;
   const match = duration.match(regex);
@@ -30,17 +35,27 @@ export function parseDuration(duration: string): number {
   }
 }
 
+/**
+ * Member banner types
+ */
 interface generateMemberBannerTypes {
   member: GuildMember;
   width: number;
   height: number;
 }
 
+/**
+ * Generates a welcome banner for a member
+ * @param member - The member to generate a banner for
+ * @param width - The width of the banner
+ * @param height - The height of the banner
+ * @returns - The generated banner
+ */
 export async function generateMemberBanner({
   member,
   width,
   height,
-}: generateMemberBannerTypes) {
+}: generateMemberBannerTypes): Promise<AttachmentBuilder> {
   const welcomeBackground = path.join(__dirname, 'assets', 'welcome-bg.png');
   const canvas = Canvas.createCanvas(width, height);
   const context = canvas.getContext('2d');
@@ -92,12 +107,19 @@ export async function generateMemberBanner({
   return attachment;
 }
 
+/**
+ * Schedules an unban for a user
+ * @param client - The client to use
+ * @param guildId - The guild ID to unban the user from
+ * @param userId - The user ID to unban
+ * @param expiresAt - The date to unban the user at
+ */
 export async function scheduleUnban(
   client: Client,
   guildId: string,
   userId: string,
   expiresAt: Date,
-) {
+): Promise<void> {
   const timeUntilUnban = expiresAt.getTime() - Date.now();
   if (timeUntilUnban > 0) {
     setTimeout(async () => {
@@ -106,12 +128,19 @@ export async function scheduleUnban(
   }
 }
 
+/**
+ * Executes an unban for a user
+ * @param client - The client to use
+ * @param guildId - The guild ID to unban the user from
+ * @param userId - The user ID to unban
+ * @param reason - The reason for the unban
+ */
 export async function executeUnban(
   client: Client,
   guildId: string,
   userId: string,
   reason?: string,
-) {
+): Promise<void> {
   try {
     const guild = await client.guilds.fetch(guildId);
     await guild.members.unban(userId, reason ?? 'Temporary ban expired');
@@ -140,26 +169,96 @@ export async function executeUnban(
       reason: reason ?? 'Temporary ban expired',
     });
   } catch (error) {
-    console.error(`Failed to unban user ${userId}:`, error);
+    handleDbError(`Failed to unban user ${userId}`, error as Error);
   }
 }
 
-export async function loadActiveBans(client: Client, guild: Guild) {
-  const activeBans = await db
-    .select()
-    .from(moderationTable)
-    .where(
-      and(eq(moderationTable.action, 'ban'), eq(moderationTable.active, true)),
-    );
+/**
+ * Loads all active bans and schedules unban events
+ * @param client - The client to use
+ * @param guild - The guild to load bans for
+ */
+export async function loadActiveBans(
+  client: Client,
+  guild: Guild,
+): Promise<void> {
+  try {
+    const activeBans = await db
+      .select()
+      .from(moderationTable)
+      .where(
+        and(
+          eq(moderationTable.action, 'ban'),
+          eq(moderationTable.active, true),
+        ),
+      );
 
-  for (const ban of activeBans) {
-    if (!ban.expiresAt) continue;
+    for (const ban of activeBans) {
+      if (!ban.expiresAt) continue;
 
-    const timeUntilUnban = ban.expiresAt.getTime() - Date.now();
-    if (timeUntilUnban <= 0) {
-      await executeUnban(client, guild.id, ban.discordId);
-    } else {
-      await scheduleUnban(client, guild.id, ban.discordId, ban.expiresAt);
+      const timeUntilUnban = ban.expiresAt.getTime() - Date.now();
+      if (timeUntilUnban <= 0) {
+        await executeUnban(client, guild.id, ban.discordId);
+      } else {
+        await scheduleUnban(client, guild.id, ban.discordId, ban.expiresAt);
+      }
     }
+  } catch (error) {
+    handleDbError('Failed to load active bans', error as Error);
+  }
+}
+
+/**
+ * Types for the roundRect function
+ */
+interface roundRectTypes {
+  ctx: Canvas.SKRSContext2D;
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  fill: boolean;
+  radius?: number;
+}
+
+/**
+ * Creates a rounded rectangle
+ * @param ctx - The canvas context to use
+ * @param x - The x position of the rectangle
+ * @param y - The y position of the rectangle
+ * @param width - The width of the rectangle
+ * @param height - The height of the rectangle
+ * @param radius - The radius of the corners
+ * @param fill - Whether to fill the rectangle
+ */
+export function roundRect({
+  ctx,
+  x,
+  y,
+  width,
+  height,
+  radius,
+  fill,
+}: roundRectTypes): void {
+  if (typeof radius === 'undefined') {
+    radius = 5;
+  }
+
+  ctx.beginPath();
+  ctx.moveTo(x + radius, y);
+  ctx.lineTo(x + width - radius, y);
+  ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
+  ctx.lineTo(x + width, y + height - radius);
+  ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
+  ctx.lineTo(x + radius, y + height);
+  ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
+  ctx.lineTo(x, y + radius);
+  ctx.quadraticCurveTo(x, y, x + radius, y);
+  ctx.closePath();
+
+  if (fill) {
+    ctx.fill();
+  } else {
+    ctx.stroke();
   }
 }
diff --git a/src/util/levelingSystem.ts b/src/util/levelingSystem.ts
index d546250..ba42ea6 100644
--- a/src/util/levelingSystem.ts
+++ b/src/util/levelingSystem.ts
@@ -1,24 +1,41 @@
 import path from 'path';
-import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.js';
 import Canvas, { GlobalFonts } from '@napi-rs/canvas';
+import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.js';
 
-import { addXpToUser, db, getUserLevel, getUserRank } from '../db/db.js';
+import {
+  addXpToUser,
+  db,
+  getUserLevel,
+  getUserRank,
+  handleDbError,
+} from '../db/db.js';
 import * as schema from '../db/schema.js';
 import { loadConfig } from './configLoader.js';
+import { roundRect } from './helpers.js';
 
 const config = loadConfig();
 
-const XP_COOLDOWN = 60 * 1000;
-const MIN_XP = 15;
-const MAX_XP = 25;
+const XP_COOLDOWN = config.leveling.xpCooldown * 1000;
+const MIN_XP = config.leveling.minXpAwarded;
+const MAX_XP = config.leveling.maxXpAwarded;
 
 const __dirname = path.resolve();
 
+/**
+ * Calculates the amount of XP required to reach the given level
+ * @param level - The level to calculate the XP for
+ * @returns - The amount of XP required to reach the given level
+ */
 export const calculateXpForLevel = (level: number): number => {
   if (level === 0) return 0;
   return (5 / 6) * level * (2 * level * level + 27 * level + 91);
 };
 
+/**
+ * Calculates the level that corresponds to the given amount of XP
+ * @param xp - The amount of XP to calculate the level for
+ * @returns - The level that corresponds to the given amount of XP
+ */
 export const calculateLevelFromXp = (xp: number): number => {
   if (xp < calculateXpForLevel(1)) return 0;
 
@@ -30,6 +47,12 @@ export const calculateLevelFromXp = (xp: number): number => {
   return level;
 };
 
+/**
+ * Gets the amount of XP required to reach the next level
+ * @param level - The level to calculate the XP for
+ * @param currentXp - The current amount of XP
+ * @returns - The amount of XP required to reach the next level
+ */
 export const getXpToNextLevel = (level: number, currentXp: number): number => {
   if (level === 0) return calculateXpForLevel(1) - currentXp;
 
@@ -37,14 +60,26 @@ export const getXpToNextLevel = (level: number, currentXp: number): number => {
   return nextLevelXp - currentXp;
 };
 
+/**
+ * Recalculates the levels for all users in the database
+ */
 export async function recalculateUserLevels() {
-  const users = await db.select().from(schema.levelTable);
+  try {
+    const users = await db.select().from(schema.levelTable);
 
-  for (const user of users) {
-    await addXpToUser(user.discordId, 0);
+    for (const user of users) {
+      await addXpToUser(user.discordId, 0);
+    }
+  } catch (error) {
+    handleDbError('Failed to recalculate user levels', error as Error);
   }
 }
 
+/**
+ * Processes a message for XP
+ * @param message - The message to process for XP
+ * @returns - The result of processing the message
+ */
 export async function processMessage(message: Message) {
   if (message.author.bot || !message.guild) return;
 
@@ -71,38 +106,12 @@ export async function processMessage(message: Message) {
   }
 }
 
-function roundRect(
-  ctx: Canvas.SKRSContext2D,
-  x: number,
-  y: number,
-  width: number,
-  height: number,
-  radius: number,
-  fill: boolean,
-) {
-  if (typeof radius === 'undefined') {
-    radius = 5;
-  }
-
-  ctx.beginPath();
-  ctx.moveTo(x + radius, y);
-  ctx.lineTo(x + width - radius, y);
-  ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
-  ctx.lineTo(x + width, y + height - radius);
-  ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
-  ctx.lineTo(x + radius, y + height);
-  ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
-  ctx.lineTo(x, y + radius);
-  ctx.quadraticCurveTo(x, y, x + radius, y);
-  ctx.closePath();
-
-  if (fill) {
-    ctx.fill();
-  } else {
-    ctx.stroke();
-  }
-}
-
+/**
+ * Generates a rank card for the given member
+ * @param member - The member to generate a rank card for
+ * @param userData - The user's level data
+ * @returns - The rank card as an attachment
+ */
 export async function generateRankCard(
   member: GuildMember,
   userData: schema.levelTableTypes,
@@ -125,7 +134,15 @@ export async function generateRankCard(
   context.fillRect(0, 0, canvas.width, canvas.height);
 
   context.fillStyle = '#2C2F33';
-  roundRect(context, 22, 22, 890, 238, 20, true);
+  roundRect({
+    ctx: context,
+    x: 22,
+    y: 22,
+    width: 890,
+    height: 238,
+    radius: 20,
+    fill: true,
+  });
 
   try {
     const avatar = await Canvas.loadImage(
@@ -183,19 +200,27 @@ export async function generateRankCard(
   );
 
   context.fillStyle = '#484b4E';
-  roundRect(context, barX, barY, barWidth, barHeight, barHeight / 2, true);
+  roundRect({
+    ctx: context,
+    x: barX,
+    y: barY,
+    width: barWidth,
+    height: barHeight,
+    radius: barHeight / 2,
+    fill: true,
+  });
 
   if (progress > 0) {
     context.fillStyle = '#5865F2';
-    roundRect(
-      context,
-      barX,
-      barY,
-      barWidth * progress,
-      barHeight,
-      barHeight / 2,
-      true,
-    );
+    roundRect({
+      ctx: context,
+      x: barX,
+      y: barY,
+      width: barWidth * progress,
+      height: barHeight,
+      radius: barHeight / 2,
+      fill: true,
+    });
   }
 
   context.textAlign = 'center';
@@ -212,6 +237,13 @@ export async function generateRankCard(
   });
 }
 
+/**
+ * Assigns level roles to a user based on their new level
+ * @param guild - The guild to assign roles in
+ * @param userId - The userId of the user to assign roles to
+ * @param newLevel - The new level of the user
+ * @returns - The highest role that was assigned
+ */
 export async function checkAndAssignLevelRoles(
   guild: Guild,
   userId: string,
diff --git a/src/util/logging/constants.ts b/src/util/logging/constants.ts
index 85ed0bf..d9c3b60 100644
--- a/src/util/logging/constants.ts
+++ b/src/util/logging/constants.ts
@@ -1,6 +1,9 @@
 import { ChannelType } from 'discord.js';
 import { LogActionType } from './types';
 
+/**
+ * Colors for different actions
+ */
 export const ACTION_COLORS: Record<string, number> = {
   // Danger actions - Red
   ban: 0xff0000,
@@ -31,6 +34,9 @@ export const ACTION_COLORS: Record<string, number> = {
   default: 0x0099ff,
 };
 
+/**
+ * Emojis for different actions
+ */
 export const ACTION_EMOJIS: Record<LogActionType, string> = {
   roleCreate: '⭐',
   roleDelete: '🗑️',
@@ -54,6 +60,9 @@ export const ACTION_EMOJIS: Record<LogActionType, string> = {
   roleRemove: '➖',
 };
 
+/**
+ * Types of channels
+ */
 export const CHANNEL_TYPES: Record<number, string> = {
   [ChannelType.GuildText]: 'Text Channel',
   [ChannelType.GuildVoice]: 'Voice Channel',
diff --git a/src/util/logging/logAction.ts b/src/util/logging/logAction.ts
index 5a0affe..a78ea48 100644
--- a/src/util/logging/logAction.ts
+++ b/src/util/logging/logAction.ts
@@ -1,5 +1,4 @@
 import {
-  TextChannel,
   ButtonStyle,
   ButtonBuilder,
   ActionRowBuilder,
@@ -25,7 +24,13 @@ import {
 } from './utils.js';
 import { loadConfig } from '../configLoader.js';
 
-export default async function logAction(payload: LogActionPayload) {
+/**
+ * Logs an action to the log channel
+ * @param payload - The payload to log
+ */
+export default async function logAction(
+  payload: LogActionPayload,
+): Promise<void> {
   const config = loadConfig();
   const logChannel = payload.guild.channels.cache.get(config.channels.logs);
   if (!logChannel?.isTextBased()) {
diff --git a/src/util/logging/types.ts b/src/util/logging/types.ts
index 36eed72..8319a3a 100644
--- a/src/util/logging/types.ts
+++ b/src/util/logging/types.ts
@@ -7,6 +7,9 @@ import {
   PermissionsBitField,
 } from 'discord.js';
 
+/**
+ * Moderation log action types
+ */
 export type ModerationActionType =
   | 'ban'
   | 'kick'
@@ -14,23 +17,38 @@ export type ModerationActionType =
   | 'unban'
   | 'unmute'
   | 'warn';
+/**
+ * Message log action types
+ */
 export type MessageActionType = 'messageDelete' | 'messageEdit';
+/**
+ * Member log action types
+ */
 export type MemberActionType =
   | 'memberJoin'
   | 'memberLeave'
   | 'memberUsernameUpdate'
   | 'memberNicknameUpdate';
+/**
+ * Role log action types
+ */
 export type RoleActionType =
   | 'roleAdd'
   | 'roleRemove'
   | 'roleCreate'
   | 'roleDelete'
   | 'roleUpdate';
+/**
+ * Channel log action types
+ */
 export type ChannelActionType =
   | 'channelCreate'
   | 'channelDelete'
   | 'channelUpdate';
 
+/**
+ * All log action types
+ */
 export type LogActionType =
   | ModerationActionType
   | MessageActionType
@@ -38,6 +56,9 @@ export type LogActionType =
   | RoleActionType
   | ChannelActionType;
 
+/**
+ * Properties of a role
+ */
 export type RoleProperties = {
   name: string;
   color: string;
@@ -45,6 +66,9 @@ export type RoleProperties = {
   mentionable: boolean;
 };
 
+/**
+ * Base log action properties
+ */
 export interface BaseLogAction {
   guild: Guild;
   action: LogActionType;
@@ -53,6 +77,9 @@ export interface BaseLogAction {
   duration?: string;
 }
 
+/**
+ * Log action properties for moderation actions
+ */
 export interface ModerationLogAction extends BaseLogAction {
   action: ModerationActionType;
   target: GuildMember;
@@ -61,6 +88,9 @@ export interface ModerationLogAction extends BaseLogAction {
   duration?: string;
 }
 
+/**
+ * Log action properties for message actions
+ */
 export interface MessageLogAction extends BaseLogAction {
   action: MessageActionType;
   message: Message<true>;
@@ -68,11 +98,17 @@ export interface MessageLogAction extends BaseLogAction {
   newContent?: string;
 }
 
+/**
+ * Log action properties for member actions
+ */
 export interface MemberLogAction extends BaseLogAction {
   action: 'memberJoin' | 'memberLeave';
   member: GuildMember;
 }
 
+/**
+ * Log action properties for member username or nickname updates
+ */
 export interface MemberUpdateAction extends BaseLogAction {
   action: 'memberUsernameUpdate' | 'memberNicknameUpdate';
   member: GuildMember;
@@ -80,6 +116,9 @@ export interface MemberUpdateAction extends BaseLogAction {
   newValue: string;
 }
 
+/**
+ * Log action properties for role actions
+ */
 export interface RoleLogAction extends BaseLogAction {
   action: 'roleAdd' | 'roleRemove';
   member: GuildMember;
@@ -87,6 +126,9 @@ export interface RoleLogAction extends BaseLogAction {
   moderator?: GuildMember;
 }
 
+/**
+ * Log action properties for role updates
+ */
 export interface RoleUpdateAction extends BaseLogAction {
   action: 'roleUpdate';
   role: Role;
@@ -97,12 +139,18 @@ export interface RoleUpdateAction extends BaseLogAction {
   moderator?: GuildMember;
 }
 
+/**
+ * Log action properties for role creation or deletion
+ */
 export interface RoleCreateDeleteAction extends BaseLogAction {
   action: 'roleCreate' | 'roleDelete';
   role: Role;
   moderator?: GuildMember;
 }
 
+/**
+ * Log action properties for channel actions
+ */
 export interface ChannelLogAction extends BaseLogAction {
   action: ChannelActionType;
   channel: GuildChannel;
@@ -123,6 +171,9 @@ export interface ChannelLogAction extends BaseLogAction {
   moderator?: GuildMember;
 }
 
+/**
+ * Payload for a log action
+ */
 export type LogActionPayload =
   | ModerationLogAction
   | MessageLogAction
diff --git a/src/util/logging/utils.ts b/src/util/logging/utils.ts
index d47d0ce..c6c2977 100644
--- a/src/util/logging/utils.ts
+++ b/src/util/logging/utils.ts
@@ -5,9 +5,15 @@ import {
   EmbedField,
   PermissionsBitField,
 } from 'discord.js';
+
 import { LogActionPayload, LogActionType, RoleProperties } from './types.js';
 import { ACTION_EMOJIS } from './constants.js';
 
+/**
+ * Formats a permission name to be more readable
+ * @param perm - The permission to format
+ * @returns - The formatted permission name
+ */
 export const formatPermissionName = (perm: string): string => {
   return perm
     .split('_')
@@ -15,6 +21,12 @@ export const formatPermissionName = (perm: string): string => {
     .join(' ');
 };
 
+/**
+ * Creates a field for a user
+ * @param user - The user to create a field for
+ * @param label - The label for the field
+ * @returns - The created field
+ */
 export const createUserField = (
   user: User | GuildMember,
   label = 'User',
@@ -24,6 +36,12 @@ export const createUserField = (
   inline: true,
 });
 
+/**
+ * Creates a field for a moderator
+ * @param moderator - The moderator to create a field for
+ * @param label - The label for the field
+ * @returns - The created field
+ */
 export const createModeratorField = (
   moderator?: GuildMember,
   label = 'Moderator',
@@ -36,12 +54,23 @@ export const createModeratorField = (
       }
     : null;
 
+/**
+ * Creates a field for a channel
+ * @param channel - The channel to create a field for
+ * @returns - The created field
+ */
 export const createChannelField = (channel: GuildChannel): EmbedField => ({
   name: 'Channel',
   value: `<#${channel.id}>`,
   inline: true,
 });
 
+/**
+ * Creates a field for changed permissions
+ * @param oldPerms - The old permissions
+ * @param newPerms - The new permissions
+ * @returns - The created fields
+ */
 export const createPermissionChangeFields = (
   oldPerms: Readonly<PermissionsBitField>,
   newPerms: Readonly<PermissionsBitField>,
@@ -84,6 +113,11 @@ export const createPermissionChangeFields = (
   return fields;
 };
 
+/**
+ * Gets the names of the permissions in a bitfield
+ * @param permissions - The permissions to get the names of
+ * @returns - The names of the permissions
+ */
 export const getPermissionNames = (
   permissions: Readonly<PermissionsBitField>,
 ): string[] => {
@@ -98,6 +132,12 @@ export const getPermissionNames = (
   return names;
 };
 
+/**
+ * Compares two bitfields and returns the names of the permissions that are in the first bitfield but not the second
+ * @param a - The first bitfield
+ * @param b - The second bitfield
+ * @returns - The names of the permissions that are in the first bitfield but not the second
+ */
 export const getPermissionDifference = (
   a: Readonly<PermissionsBitField>,
   b: Readonly<PermissionsBitField>,
@@ -114,6 +154,12 @@ export const getPermissionDifference = (
   return names;
 };
 
+/**
+ * Creates a field for a role
+ * @param oldRole - The old role
+ * @param newRole - The new role
+ * @returns - The fields for the role changes
+ */
 export const createRoleChangeFields = (
   oldRole: Partial<RoleProperties>,
   newRole: Partial<RoleProperties>,
@@ -153,6 +199,11 @@ export const createRoleChangeFields = (
   return fields;
 };
 
+/**
+ * Gets the ID of the item that was logged
+ * @param payload - The payload to get the log item ID from
+ * @returns - The ID of the log item
+ */
 export const getLogItemId = (payload: LogActionPayload): string => {
   switch (payload.action) {
     case 'roleCreate':
@@ -188,6 +239,11 @@ export const getLogItemId = (payload: LogActionPayload): string => {
   }
 };
 
+/**
+ * Gets the emoji for an action
+ * @param action - The action to get an emoji for
+ * @returns - The emoji for the action
+ */
 export const getEmojiForAction = (action: LogActionType): string => {
   return ACTION_EMOJIS[action] || '📝';
 };
diff --git a/src/util/notificationHandler.ts b/src/util/notificationHandler.ts
new file mode 100644
index 0000000..a2f8a58
--- /dev/null
+++ b/src/util/notificationHandler.ts
@@ -0,0 +1,151 @@
+import { Client, Guild, GuildMember } from 'discord.js';
+import { loadConfig } from './configLoader.js';
+
+/**
+ * Types of notifications that can be sent
+ */
+export enum NotificationType {
+  // Redis notifications
+  REDIS_CONNECTION_LOST = 'REDIS_CONNECTION_LOST',
+  REDIS_CONNECTION_RESTORED = 'REDIS_CONNECTION_RESTORED',
+
+  // Database notifications
+  DATABASE_CONNECTION_LOST = 'DATABASE_CONNECTION_LOST',
+  DATABASE_CONNECTION_RESTORED = 'DATABASE_CONNECTION_RESTORED',
+
+  // Bot notifications
+  BOT_RESTARTING = 'BOT_RESTARTING',
+  BOT_ERROR = 'BOT_ERROR',
+}
+
+/**
+ * Maps notification types to their messages
+ */
+const NOTIFICATION_MESSAGES = {
+  [NotificationType.REDIS_CONNECTION_LOST]:
+    '⚠️ **Redis Connection Lost**\n\nThe bot has lost connection to Redis after multiple retry attempts. Caching functionality is disabled until the connection is restored.',
+  [NotificationType.REDIS_CONNECTION_RESTORED]:
+    '✅ **Redis Connection Restored**\n\nThe bot has successfully reconnected to Redis. All caching functionality has been restored.',
+
+  [NotificationType.DATABASE_CONNECTION_LOST]:
+    '🚨 **Database Connection Lost**\n\nThe bot has lost connection to the database after multiple retry attempts. The bot cannot function properly without database access and will shut down.',
+  [NotificationType.DATABASE_CONNECTION_RESTORED]:
+    '✅ **Database Connection Restored**\n\nThe bot has successfully reconnected to the database.',
+
+  [NotificationType.BOT_RESTARTING]:
+    '🔄 **Bot Restarting**\n\nThe bot is being restarted. Services will be temporarily unavailable.',
+  [NotificationType.BOT_ERROR]:
+    '🚨 **Critical Bot Error**\n\nThe bot has encountered a critical error and may not function correctly.',
+};
+
+/**
+ * Creates a Discord-friendly timestamp string
+ * @returns Formatted Discord timestamp string
+ */
+function createDiscordTimestamp(): string {
+  const timestamp = Math.floor(Date.now() / 1000);
+  return `<t:${timestamp}:F> (<t:${timestamp}:R>)`;
+}
+
+/**
+ * Gets all managers with the Manager role
+ * @param guild - The guild to search in
+ * @returns Array of members with the Manager role
+ */
+async function getManagers(guild: Guild): Promise<GuildMember[]> {
+  const config = loadConfig();
+  const managerRoleId = config.roles?.staffRoles?.find(
+    (role) => role.name === 'Manager',
+  )?.roleId;
+
+  if (!managerRoleId) {
+    console.warn('Manager role not found in config');
+    return [];
+  }
+
+  try {
+    await guild.members.fetch();
+
+    return Array.from(
+      guild.members.cache
+        .filter(
+          (member) => member.roles.cache.has(managerRoleId) && !member.user.bot,
+        )
+        .values(),
+    );
+  } catch (error) {
+    console.error('Error fetching managers:', error);
+    return [];
+  }
+}
+
+/**
+ * Sends a notification to users with the Manager role
+ * @param client - Discord client instance
+ * @param type - Type of notification to send
+ * @param customMessage - Optional custom message to append
+ */
+export async function notifyManagers(
+  client: Client,
+  type: NotificationType,
+  customMessage?: string,
+): Promise<void> {
+  try {
+    const config = loadConfig();
+    const guild = client.guilds.cache.get(config.guildId);
+
+    if (!guild) {
+      console.error(`Guild with ID ${config.guildId} not found`);
+      return;
+    }
+
+    const managers = await getManagers(guild);
+
+    if (managers.length === 0) {
+      console.warn('No managers found to notify');
+      return;
+    }
+
+    const baseMessage = NOTIFICATION_MESSAGES[type];
+    const timestamp = createDiscordTimestamp();
+    const fullMessage = customMessage
+      ? `${baseMessage}\n\n${customMessage}`
+      : baseMessage;
+
+    let successCount = 0;
+    for (const manager of managers) {
+      try {
+        await manager.send({
+          content: `${fullMessage}\n\nTimestamp: ${timestamp}`,
+        });
+        successCount++;
+      } catch (error) {
+        console.error(
+          `Failed to send DM to manager ${manager.user.tag}:`,
+          error,
+        );
+      }
+    }
+
+    console.log(
+      `Sent ${type} notification to ${successCount}/${managers.length} managers`,
+    );
+  } catch (error) {
+    console.error('Error sending manager notifications:', error);
+  }
+}
+
+/**
+ * Log a manager-level notification to the console
+ * @param type - Type of notification
+ * @param details - Additional details
+ */
+export function logManagerNotification(
+  type: NotificationType,
+  details?: string,
+): void {
+  const baseMessage = NOTIFICATION_MESSAGES[type].split('\n')[0];
+  console.warn(
+    `MANAGER NOTIFICATION: ${baseMessage}${details ? ` | ${details}` : ''}`,
+  );
+}