diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0c0b95e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Dependencies - handled inside container +node_modules +web/node_modules + +# Git +.git +.gitignore + +# Logs and data +logs +*.log +shared/db/data +shared/db/log + +# Development tools +.env +.env.example +.opencode +.agent + +# Documentation +docs +*.md +!README.md + +# IDE +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +dist +.cache +*.tsbuildinfo diff --git a/.gitignore b/.gitignore index 956a946..0ac8ac8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .env node_modules +docker-compose.override.yml shared/db-logs shared/db/data shared/db/loga @@ -44,4 +45,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json src/db/data src/db/log -scratchpad/ \ No newline at end of file +scratchpad/ +tickets/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..627ccf0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,244 @@ +# AGENTS.md - AI Coding Agent Guidelines + +## Project Overview + +AuroraBot is a Discord bot with a web dashboard built using Bun, Discord.js, React, and PostgreSQL with Drizzle ORM. + +## Build/Lint/Test Commands + +```bash +# Development +bun --watch bot/index.ts # Run bot with hot reload +bun --hot web/src/index.ts # Run web dashboard with hot reload + +# Testing +bun test # Run all tests ( expect some tests to fail when running all at once like this due to the nature of the tests ) +bun test path/to/file.test.ts # Run single test file +bun test --watch # Watch mode +bun test shared/modules/economy # Run tests in directory + +# Database +bun run generate # Generate Drizzle migrations (Docker) +bun run migrate # Run migrations (Docker) +bun run db:push # Push schema changes (Docker) +bun run db:push:local # Push schema changes (local) +bun run db:studio # Open Drizzle Studio + +# Web Dashboard +cd web && bun run build # Build production web assets +cd web && bun run dev # Development server +``` + +## Project Structure + +``` +bot/ # Discord bot +├── commands/ # Slash commands by category +├── events/ # Discord event handlers +├── lib/ # Bot core (BotClient, handlers, loaders) +├── modules/ # Feature modules (views, interactions) +└── graphics/ # Canvas image generation + +shared/ # Shared between bot and web +├── db/ # Database schema and migrations +├── lib/ # Utils, config, errors, types +└── modules/ # Domain services (economy, user, etc.) + +web/ # React dashboard +├── src/pages/ # React pages +├── src/components/ # UI components (ShadCN/Radix) +└── src/hooks/ # React hooks +``` + +## Import Conventions + +Use path aliases defined in tsconfig.json: + +```typescript +// External packages first +import { SlashCommandBuilder } from "discord.js"; +import { eq } from "drizzle-orm"; + +// Path aliases second +import { economyService } from "@shared/modules/economy/economy.service"; +import { UserError } from "@shared/lib/errors"; +import { users } from "@db/schema"; +import { createErrorEmbed } from "@lib/embeds"; +import { handleTradeInteraction } from "@modules/trade/trade.interaction"; + +// Relative imports last +import { localHelper } from "./helper"; +``` + +**Available Aliases:** + +- `@/*` - bot/ +- `@shared/*` - shared/ +- `@db/*` - shared/db/ +- `@lib/*` - bot/lib/ +- `@modules/*` - bot/modules/ +- `@commands/*` - bot/commands/ + +## Naming Conventions + +| Element | Convention | Example | +| ---------------- | ----------------------- | ---------------------------------------- | +| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` | +| Classes | PascalCase | `CommandHandler`, `UserError` | +| Functions | camelCase | `createCommand`, `handleShopInteraction` | +| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` | +| Enums | PascalCase | `TimerType`, `TransactionType` | +| Services | camelCase singleton | `economyService`, `userService` | +| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` | +| DB tables | snake_case | `users`, `moderation_cases` | +| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` | + +## Code Patterns + +### Command Definition + +```typescript +export const commandName = createCommand({ + data: new SlashCommandBuilder() + .setName("commandname") + .setDescription("Description"), + execute: async (interaction) => { + await interaction.deferReply(); + // Implementation + }, +}); +``` + +### Service Pattern (Singleton Object) + +```typescript +export const serviceName = { + methodName: async (params: ParamType): Promise => { + return await withTransaction(async (tx) => { + // Database operations + }); + }, +}; +``` + +### Module File Organization + +- `*.view.ts` - Creates Discord embeds/components +- `*.interaction.ts` - Handles button/select/modal interactions +- `*.types.ts` - Module-specific TypeScript types +- `*.service.ts` - Business logic (in shared/modules/) +- `*.test.ts` - Test files (co-located with source) + +## Error Handling + +### Custom Error Classes + +```typescript +import { UserError, SystemError } from "@shared/lib/errors"; + +// User-facing errors (shown to user) +throw new UserError("You don't have enough coins!"); + +// System errors (logged, generic message shown) +throw new SystemError("Database connection failed"); +``` + +### Standard Error Pattern + +```typescript +try { + const result = await service.method(); + await interaction.editReply({ embeds: [createSuccessEmbed(result)] }); +} catch (error) { + if (error instanceof UserError) { + await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); + } else { + console.error("Unexpected error:", error); + await interaction.editReply({ + embeds: [createErrorEmbed("An unexpected error occurred.")], + }); + } +} +``` + +## Database Patterns + +### Transaction Usage + +```typescript +import { withTransaction } from "@/lib/db"; + +return await withTransaction(async (tx) => { + const user = await tx.query.users.findFirst({ + where: eq(users.id, discordId), + }); + + await tx + .update(users) + .set({ coins: newBalance }) + .where(eq(users.id, discordId)); + await tx.insert(transactions).values({ userId: discordId, amount, type }); + + return user; +}, existingTx); // Pass existing tx if in nested transaction +``` + +### Schema Notes + +- Use `bigint` mode for Discord IDs and currency amounts +- Relations defined separately from table definitions +- Schema location: `shared/db/schema.ts` + +## Testing + +### Test File Structure + +```typescript +import { describe, it, expect, mock, beforeEach } from "bun:test"; + +// Mock modules BEFORE imports +mock.module("@shared/db/DrizzleClient", () => ({ + DrizzleClient: { query: mockQuery }, +})); + +describe("serviceName", () => { + beforeEach(() => { + mockFn.mockClear(); + }); + + it("should handle expected case", async () => { + // Arrange + mockFn.mockResolvedValue(testData); + + // Act + const result = await service.method(input); + + // Assert + expect(result).toEqual(expected); + expect(mockFn).toHaveBeenCalledWith(expectedArgs); + }); +}); +``` + +## Tech Stack + +- **Runtime:** Bun 1.0+ +- **Bot:** Discord.js 14.x +- **Web:** React 19 + Bun HTTP Server +- **Database:** PostgreSQL 16+ with Drizzle ORM +- **UI:** Tailwind CSS v4 + ShadCN/Radix +- **Validation:** Zod +- **Testing:** Bun Test +- **Container:** Docker + +## Key Files Reference + +| Purpose | File | +| ------------- | ---------------------- | +| Bot entry | `bot/index.ts` | +| DB schema | `shared/db/schema.ts` | +| Error classes | `shared/lib/errors.ts` | +| Config loader | `shared/lib/config.ts` | +| Environment | `shared/lib/env.ts` | +| Embed helpers | `bot/lib/embeds.ts` | +| Command utils | `shared/lib/utils.ts` | diff --git a/Dockerfile b/Dockerfile index a35eadf..b38e0e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,55 @@ +# ============================================ +# Base stage - shared configuration +# ============================================ FROM oven/bun:latest AS base WORKDIR /app -# Install system dependencies -RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* +# Install system dependencies with cleanup in same layer +RUN apt-get update && \ + apt-get install -y --no-install-recommends git && \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* -# Install root project dependencies +# ============================================ +# Dependencies stage - installs all deps +# ============================================ +FROM base AS deps + +# Copy only package files first (better layer caching) COPY package.json bun.lock ./ -RUN bun install --frozen-lockfile - -# Install web project dependencies COPY web/package.json web/bun.lock ./web/ -RUN cd web && bun install --frozen-lockfile -# Copy source code -COPY . . +# Install all dependencies in one layer +RUN bun install --frozen-lockfile && \ + cd web && bun install --frozen-lockfile -# Expose ports (3000 for web dashboard) +# ============================================ +# Development stage - for local dev with volume mounts +# ============================================ +FROM base AS development + +# Copy dependencies from deps stage +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/web/node_modules ./web/node_modules + +# Expose ports +EXPOSE 3000 + +# Default command +CMD ["bun", "run", "dev"] + +# ============================================ +# Production stage - full app with source code +# ============================================ +FROM base AS production + +# Copy dependencies from deps stage +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/web/node_modules ./web/node_modules + +# Copy source code +COPY . . + +# Expose ports EXPOSE 3000 # Default command diff --git a/bot/commands/admin/listing.ts b/bot/commands/admin/listing.ts index a9f15b6..7468e18 100644 --- a/bot/commands/admin/listing.ts +++ b/bot/commands/admin/listing.ts @@ -10,7 +10,7 @@ import { } from "discord.js"; import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; import { items } from "@db/schema"; import { ilike, isNotNull, and } from "drizzle-orm"; import { DrizzleClient } from "@shared/db/DrizzleClient"; @@ -65,10 +65,10 @@ export const listing = createCommand({ await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` }); } catch (error: any) { if (error instanceof UserError) { - await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true }); + await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); } else { console.error("Error creating listing:", error); - await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true }); + await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); } } }, diff --git a/bot/commands/admin/update.ts b/bot/commands/admin/update.ts index 90a5340..ab7d05f 100644 --- a/bot/commands/admin/update.ts +++ b/bot/commands/admin/update.ts @@ -49,7 +49,7 @@ async function handleUpdate(interaction: any) { const force = interaction.options.getBoolean("force") || false; try { - // 1. Check for updates + // 1. Check for updates (now includes requirements in one call) await interaction.editReply({ embeds: [getCheckingEmbed()] }); const updateInfo = await UpdateService.checkForUpdates(); @@ -60,8 +60,8 @@ async function handleUpdate(interaction: any) { return; } - // 2. Analyze requirements - const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch); + // 2. Extract requirements from the combined response + const { requirements } = updateInfo; const categories = UpdateService.categorizeChanges(requirements.changedFiles); // 3. Show confirmation with details @@ -97,6 +97,7 @@ async function handleUpdate(interaction: any) { timestamp: Date.now(), runMigrations: requirements.needsMigrations, installDependencies: requirements.needsRootInstall || requirements.needsWebInstall, + buildWebAssets: requirements.needsWebBuild, previousCommit: previousCommit.substring(0, 7), newCommit: updateInfo.latestCommit }); diff --git a/bot/commands/economy/daily.ts b/bot/commands/economy/daily.ts index 3a6b5c4..09a6b19 100644 --- a/bot/commands/economy/daily.ts +++ b/bot/commands/economy/daily.ts @@ -3,13 +3,14 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder } from "discord.js"; import { economyService } from "@shared/modules/economy/economy.service"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; export const daily = createCommand({ data: new SlashCommandBuilder() .setName("daily") .setDescription("Claim your daily reward"), execute: async (interaction) => { + await interaction.deferReply(); try { const result = await economyService.claimDaily(interaction.user.id); @@ -21,14 +22,14 @@ export const daily = createCommand({ ) .setColor("Gold"); - await interaction.reply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed] }); } catch (error: any) { if (error instanceof UserError) { - await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true }); + await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); } else { console.error("Error claiming daily:", error); - await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true }); + await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); } } } diff --git a/bot/commands/economy/exam.ts b/bot/commands/economy/exam.ts index 890d184..f7bfde2 100644 --- a/bot/commands/economy/exam.ts +++ b/bot/commands/economy/exam.ts @@ -1,21 +1,7 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder } from "discord.js"; -import { userService } from "@shared/modules/user/user.service"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; -import { UserError } from "@/lib/errors"; -import { userTimers, users } from "@db/schema"; -import { eq, and, sql } from "drizzle-orm"; -import { DrizzleClient } from "@shared/db/DrizzleClient"; -import { config } from "@shared/lib/config"; -import { TimerType } from "@shared/lib/constants"; - -const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM; -const EXAM_TIMER_KEY = 'default'; - -interface ExamMetadata { - examDay: number; - lastXp: string; -} +import { examService, ExamStatus } from "@shared/modules/economy/exam.service"; const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; @@ -25,105 +11,42 @@ export const exam = createCommand({ .setDescription("Take your weekly exam to earn rewards based on your XP progress."), execute: async (interaction) => { await interaction.deferReply(); - const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username); - if (!user) { - await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] }); - return; - } - const now = new Date(); - const currentDay = now.getDay(); try { - // 1. Fetch existing timer/exam data - const timer = await DrizzleClient.query.userTimers.findFirst({ - where: and( - eq(userTimers.userId, user.id), - eq(userTimers.type, EXAM_TIMER_TYPE), - eq(userTimers.key, EXAM_TIMER_KEY) - ) - }); + // First, try to take the exam or check status + const result = await examService.takeExam(interaction.user.id); - // 2. First Run Logic - if (!timer) { - // Set exam day to today - const nextExamDate = new Date(now); - nextExamDate.setDate(now.getDate() + 7); - nextExamDate.setHours(0, 0, 0, 0); - const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000); - - const metadata: ExamMetadata = { - examDay: currentDay, - lastXp: (user.xp ?? 0n).toString() - }; - - await DrizzleClient.insert(userTimers).values({ - userId: user.id, - type: EXAM_TIMER_TYPE, - key: EXAM_TIMER_KEY, - expiresAt: nextExamDate, - metadata: metadata - }); + if (result.status === ExamStatus.NOT_REGISTERED) { + // Register the user + const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username); + const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000); await interaction.editReply({ embeds: [createSuccessEmbed( - `You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` + - `Come back on () to take your first exam!`, + `You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` + + `Come back on () to take your first exam!`, "Exam Registration Successful" )] }); return; } - const metadata = timer.metadata as unknown as ExamMetadata; - const examDay = metadata.examDay; - - // 3. Cooldown Check - const expiresAt = new Date(timer.expiresAt); - expiresAt.setHours(0, 0, 0, 0); - - if (now < expiresAt) { - // Calculate time remaining - const timestamp = Math.floor(expiresAt.getTime() / 1000); + const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000); + if (result.status === ExamStatus.COOLDOWN) { await interaction.editReply({ embeds: [createErrorEmbed( `You have already taken your exam for this week (or are waiting for your first week to pass).\n` + - `Next exam available: ()` + `Next exam available: ()` )] }); return; } - // 4. Day Check - if (currentDay !== examDay) { - // Calculate next correct exam day to correct the schedule - let daysUntil = (examDay - currentDay + 7) % 7; - if (daysUntil === 0) daysUntil = 7; - - const nextExamDate = new Date(now); - nextExamDate.setDate(now.getDate() + daysUntil); - nextExamDate.setHours(0, 0, 0, 0); - const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000); - - const newMetadata: ExamMetadata = { - examDay: examDay, - lastXp: (user.xp ?? 0n).toString() - }; - - await DrizzleClient.update(userTimers) - .set({ - expiresAt: nextExamDate, - metadata: newMetadata - }) - .where(and( - eq(userTimers.userId, user.id), - eq(userTimers.type, EXAM_TIMER_TYPE), - eq(userTimers.key, EXAM_TIMER_KEY) - )); - + if (result.status === ExamStatus.MISSED) { await interaction.editReply({ embeds: [createErrorEmbed( - `You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` + + `You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` + `You verify your attendance but score a **0**.\n` + `Your next exam opportunity is: ()`, "Exam Failed" @@ -132,74 +55,21 @@ export const exam = createCommand({ return; } - // 5. Reward Calculation - const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case - const currentXp = user.xp ?? 0n; - const diff = currentXp - lastXp; - - // Calculate Reward - const multMin = config.economy.exam.multMin; - const multMax = config.economy.exam.multMax; - const multiplier = Math.random() * (multMax - multMin) + multMin; - - // Allow negative reward? existing description implies "difference", usually gain. - // If diff is negative (lost XP?), reward might be 0. - let reward = 0n; - if (diff > 0n) { - reward = BigInt(Math.floor(Number(diff) * multiplier)); - } - - // 6. Update State - const nextExamDate = new Date(now); - nextExamDate.setDate(now.getDate() + 7); - nextExamDate.setHours(0, 0, 0, 0); - const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000); - - const newMetadata: ExamMetadata = { - examDay: examDay, - lastXp: currentXp.toString() - }; - - await DrizzleClient.transaction(async (tx) => { - // Update Timer - await tx.update(userTimers) - .set({ - expiresAt: nextExamDate, - metadata: newMetadata - }) - .where(and( - eq(userTimers.userId, user.id), - eq(userTimers.type, EXAM_TIMER_TYPE), - eq(userTimers.key, EXAM_TIMER_KEY) - )); - - // Add Currency - if (reward > 0n) { - await tx.update(users) - .set({ - balance: sql`${users.balance} + ${reward}` - }) - .where(eq(users.id, user.id)); - } - }); - + // If it reached here with AVAILABLE, it means they passed await interaction.editReply({ embeds: [createSuccessEmbed( - `**XP Gained:** ${diff.toString()}\n` + - `**Multiplier:** x${multiplier.toFixed(2)}\n` + - `**Reward:** ${reward.toString()} Currency\n\n` + + `**XP Gained:** ${result.xpDiff?.toString()}\n` + + `**Multiplier:** x${result.multiplier?.toFixed(2)}\n` + + `**Reward:** ${result.reward?.toString()} Currency\n\n` + `See you next week: `, "Exam Passed!" )] }); } catch (error: any) { - if (error instanceof UserError) { - await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true }); - } else { - console.error("Error in exam command:", error); - await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true }); - } + console.error("Error in exam command:", error); + await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] }); } } }); + diff --git a/bot/commands/economy/pay.ts b/bot/commands/economy/pay.ts index 2d87899..b51163d 100644 --- a/bot/commands/economy/pay.ts +++ b/bot/commands/economy/pay.ts @@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service"; import { userService } from "@shared/modules/user/user.service"; import { config } from "@shared/lib/config"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; export const pay = createCommand({ data: new SlashCommandBuilder() diff --git a/bot/commands/economy/trivia.ts b/bot/commands/economy/trivia.ts index f35a567..3463bfa 100644 --- a/bot/commands/economy/trivia.ts +++ b/bot/commands/economy/trivia.ts @@ -3,7 +3,7 @@ import { SlashCommandBuilder } from "discord.js"; import { triviaService } from "@shared/modules/trivia/trivia.service"; import { getTriviaQuestionView } from "@/modules/trivia/trivia.view"; import { createErrorEmbed } from "@lib/embeds"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; import { config } from "@shared/lib/config"; import { TriviaCategory } from "@shared/lib/constants"; diff --git a/bot/commands/inventory/use.ts b/bot/commands/inventory/use.ts index 448a346..d425395 100644 --- a/bot/commands/inventory/use.ts +++ b/bot/commands/inventory/use.ts @@ -5,7 +5,7 @@ import { userService } from "@shared/modules/user/user.service"; import { createErrorEmbed } from "@lib/embeds"; import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view"; import type { ItemUsageData } from "@shared/lib/types"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; import { config } from "@shared/lib/config"; export const use = createCommand({ diff --git a/bot/commands/quest/quests.ts b/bot/commands/quest/quests.ts index fab6621..a3f053a 100644 --- a/bot/commands/quest/quests.ts +++ b/bot/commands/quest/quests.ts @@ -1,25 +1,83 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder, MessageFlags } from "discord.js"; import { questService } from "@shared/modules/quest/quest.service"; -import { createWarningEmbed } from "@lib/embeds"; -import { getQuestListEmbed } from "@/modules/quest/quest.view"; +import { createSuccessEmbed } from "@lib/embeds"; +import { + getQuestListComponents, + getAvailableQuestsComponents, + getQuestActionRows +} from "@/modules/quest/quest.view"; export const quests = createCommand({ data: new SlashCommandBuilder() .setName("quests") - .setDescription("View your active quests"), + .setDescription("View your active and available quests"), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const userQuests = await questService.getUserQuests(interaction.user.id); + const userId = interaction.user.id; - if (!userQuests || userQuests.length === 0) { - await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] }); - return; - } + const updateView = async (viewType: 'active' | 'available') => { + const userQuests = await questService.getUserQuests(userId); + const availableQuests = await questService.getAvailableQuests(userId); - const embed = getQuestListEmbed(userQuests); + const containers = viewType === 'active' + ? getQuestListComponents(userQuests) + : getAvailableQuestsComponents(availableQuests); - await interaction.editReply({ embeds: [embed] }); + const actionRows = getQuestActionRows(viewType); + + await interaction.editReply({ + content: null, + embeds: null as any, + components: [...containers, ...actionRows] as any, + flags: MessageFlags.IsComponentsV2, + allowedMentions: { parse: [] } + }); + }; + + // Initial view + await updateView('active'); + + const collector = response.createMessageComponentCollector({ + time: 120000, // 2 minutes + componentType: undefined // Allow buttons + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) return; + + try { + if (i.customId === "quest_view_active") { + await i.deferUpdate(); + await updateView('active'); + } else if (i.customId === "quest_view_available") { + await i.deferUpdate(); + await updateView('available'); + } else if (i.customId.startsWith("quest_accept:")) { + const questIdStr = i.customId.split(":")[1]; + if (!questIdStr) return; + const questId = parseInt(questIdStr); + await questService.assignQuest(userId, questId); + + await i.reply({ + embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")], + flags: MessageFlags.Ephemeral + }); + + await updateView('active'); + } + } catch (error) { + console.error("Quest interaction error:", error); + await i.followUp({ + content: "Something went wrong while processing your quest interaction.", + flags: MessageFlags.Ephemeral + }); + } + }); + + collector.on('end', () => { + interaction.editReply({ components: [] }).catch(() => {}); + }); } }); diff --git a/bot/lib/BotClient.ts b/bot/lib/BotClient.ts index d347f08..de0fdb6 100644 --- a/bot/lib/BotClient.ts +++ b/bot/lib/BotClient.ts @@ -1,4 +1,4 @@ -import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js"; +import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, MessageFlags } from "discord.js"; import { join } from "node:path"; import type { Command } from "@shared/lib/types"; import { env } from "@shared/lib/env"; @@ -74,6 +74,27 @@ export class Client extends DiscordClient { console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`); this.maintenanceMode = enabled; }); + + systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => { + const { userId, quest, rewards } = data; + try { + const user = await this.users.fetch(userId); + if (!user) return; + + const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view"); + const components = getQuestCompletionComponents(quest, rewards); + + // Try to send to the user's DM + await user.send({ + components: components as any, + flags: [MessageFlags.IsComponentsV2] + }).catch(async () => { + console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`); + }); + } catch (error) { + console.error("Failed to send quest completion notification:", error); + } + }); } async loadCommands(reload: boolean = false) { @@ -176,4 +197,4 @@ export class Client extends DiscordClient { } } -export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] }); \ No newline at end of file +export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] }); \ No newline at end of file diff --git a/bot/lib/clientStats.ts b/bot/lib/clientStats.ts index 92cf657..3cbbb79 100644 --- a/bot/lib/clientStats.ts +++ b/bot/lib/clientStats.ts @@ -23,6 +23,7 @@ export function getClientStats(): ClientStats { bot: { name: AuroraClient.user?.username || "Aurora", avatarUrl: AuroraClient.user?.displayAvatarURL() || null, + status: AuroraClient.user?.presence.activities[0]?.state || AuroraClient.user?.presence.activities[0]?.name || null, }, guilds: AuroraClient.guilds.cache.size, ping: AuroraClient.ws.ping, diff --git a/bot/lib/embeds.ts b/bot/lib/embeds.ts index 6e618af..e8645d0 100644 --- a/bot/lib/embeds.ts +++ b/bot/lib/embeds.ts @@ -1,4 +1,15 @@ import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js"; +import { BRANDING } from "@shared/lib/constants"; +import pkg from "../../package.json"; + +/** + * Applies standard branding to an embed. + */ +function applyBranding(embed: EmbedBuilder): EmbedBuilder { + return embed.setFooter({ + text: `${BRANDING.FOOTER_TEXT} v${pkg.version}` + }); +} /** * Creates a standardized error embed. @@ -7,11 +18,13 @@ import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js"; * @returns An EmbedBuilder instance configured as an error. */ export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder { - return new EmbedBuilder() + const embed = new EmbedBuilder() .setTitle(`❌ ${title}`) .setDescription(message) .setColor(Colors.Red) .setTimestamp(); + + return applyBranding(embed); } /** @@ -21,11 +34,13 @@ export function createErrorEmbed(message: string, title: string = "Error"): Embe * @returns An EmbedBuilder instance configured as a warning. */ export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder { - return new EmbedBuilder() + const embed = new EmbedBuilder() .setTitle(`⚠️ ${title}`) .setDescription(message) .setColor(Colors.Yellow) .setTimestamp(); + + return applyBranding(embed); } /** @@ -35,11 +50,13 @@ export function createWarningEmbed(message: string, title: string = "Warning"): * @returns An EmbedBuilder instance configured as a success. */ export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder { - return new EmbedBuilder() + const embed = new EmbedBuilder() .setTitle(`✅ ${title}`) .setDescription(message) .setColor(Colors.Green) .setTimestamp(); + + return applyBranding(embed); } /** @@ -49,11 +66,13 @@ export function createSuccessEmbed(message: string, title: string = "Success"): * @returns An EmbedBuilder instance configured as info. */ export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder { - return new EmbedBuilder() + const embed = new EmbedBuilder() .setTitle(`ℹ️ ${title}`) .setDescription(message) .setColor(Colors.Blue) .setTimestamp(); + + return applyBranding(embed); } /** @@ -65,11 +84,12 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB */ export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder { const embed = new EmbedBuilder() - .setTimestamp(); + .setTimestamp() + .setColor(color ?? BRANDING.COLOR); if (title) embed.setTitle(title); if (description) embed.setDescription(description); - if (color) embed.setColor(color); - return embed; + return applyBranding(embed); } + diff --git a/bot/lib/errors.ts b/bot/lib/errors.ts deleted file mode 100644 index 32bce8c..0000000 --- a/bot/lib/errors.ts +++ /dev/null @@ -1,18 +0,0 @@ -export class ApplicationError extends Error { - constructor(message: string) { - super(message); - this.name = this.constructor.name; - } -} - -export class UserError extends ApplicationError { - constructor(message: string) { - super(message); - } -} - -export class SystemError extends ApplicationError { - constructor(message: string) { - super(message); - } -} diff --git a/bot/lib/handlers/AutocompleteHandler.ts b/bot/lib/handlers/AutocompleteHandler.ts index 61b5132..d5a0498 100644 --- a/bot/lib/handlers/AutocompleteHandler.ts +++ b/bot/lib/handlers/AutocompleteHandler.ts @@ -1,5 +1,6 @@ import { AutocompleteInteraction } from "discord.js"; import { AuroraClient } from "@/lib/BotClient"; +import { logger } from "@shared/lib/logger"; /** @@ -16,7 +17,7 @@ export class AutocompleteHandler { try { await command.autocomplete(interaction); } catch (error) { - console.error(`Error handling autocomplete for ${interaction.commandName}:`, error); + logger.error("bot", `Error handling autocomplete for ${interaction.commandName}`, error); } } } diff --git a/bot/lib/handlers/CommandHandler.ts b/bot/lib/handlers/CommandHandler.ts index 3fc8647..eeb4b08 100644 --- a/bot/lib/handlers/CommandHandler.ts +++ b/bot/lib/handlers/CommandHandler.ts @@ -2,6 +2,7 @@ import { ChatInputCommandInteraction, MessageFlags } from "discord.js"; import { AuroraClient } from "@/lib/BotClient"; import { userService } from "@shared/modules/user/user.service"; import { createErrorEmbed } from "@lib/embeds"; +import { logger } from "@shared/lib/logger"; /** @@ -13,7 +14,7 @@ export class CommandHandler { const command = AuroraClient.commands.get(interaction.commandName); if (!command) { - console.error(`No command matching ${interaction.commandName} was found.`); + logger.error("bot", `No command matching ${interaction.commandName} was found.`); return; } @@ -28,14 +29,14 @@ export class CommandHandler { try { await userService.getOrCreateUser(interaction.user.id, interaction.user.username); } catch (error) { - console.error("Failed to ensure user exists:", error); + logger.error("bot", "Failed to ensure user exists", error); } try { await command.execute(interaction); AuroraClient.lastCommandTimestamp = Date.now(); } catch (error) { - console.error(String(error)); + logger.error("bot", `Error executing command ${interaction.commandName}`, error); const errorEmbed = createErrorEmbed('There was an error while executing this command!'); if (interaction.replied || interaction.deferred) { diff --git a/bot/lib/handlers/ComponentInteractionHandler.ts b/bot/lib/handlers/ComponentInteractionHandler.ts index 4773bee..f99d127 100644 --- a/bot/lib/handlers/ComponentInteractionHandler.ts +++ b/bot/lib/handlers/ComponentInteractionHandler.ts @@ -1,7 +1,8 @@ import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js"; -import { UserError } from "@lib/errors"; +import { UserError } from "@shared/lib/errors"; import { createErrorEmbed } from "@lib/embeds"; +import { logger } from "@shared/lib/logger"; type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction; @@ -28,7 +29,7 @@ export class ComponentInteractionHandler { return; } } else { - console.error(`Handler method ${route.method} not found in module`); + logger.error("bot", `Handler method ${route.method} not found in module`); } } } @@ -52,7 +53,7 @@ export class ComponentInteractionHandler { // Log system errors (non-user errors) for debugging if (!isUserError) { - console.error(`Error in ${handlerName}:`, error); + logger.error("bot", `Error in ${handlerName}`, error); } const errorEmbed = createErrorEmbed(errorMessage); @@ -72,7 +73,7 @@ export class ComponentInteractionHandler { } } catch (replyError) { // If we can't send a reply, log it - console.error(`Failed to send error response in ${handlerName}:`, replyError); + logger.error("bot", `Failed to send error response in ${handlerName}`, replyError); } } } diff --git a/bot/modules/admin/update.types.ts b/bot/modules/admin/update.types.ts index 633a6ad..8098d38 100644 --- a/bot/modules/admin/update.types.ts +++ b/bot/modules/admin/update.types.ts @@ -5,6 +5,7 @@ export interface RestartContext { timestamp: number; runMigrations: boolean; installDependencies: boolean; + buildWebAssets: boolean; previousCommit: string; newCommit: string; } @@ -12,6 +13,7 @@ export interface RestartContext { export interface UpdateCheckResult { needsRootInstall: boolean; needsWebInstall: boolean; + needsWebBuild: boolean; needsMigrations: boolean; changedFiles: string[]; error?: Error; diff --git a/bot/modules/admin/update.view.ts b/bot/modules/admin/update.view.ts index 734d37b..bb74e53 100644 --- a/bot/modules/admin/update.view.ts +++ b/bot/modules/admin/update.view.ts @@ -31,7 +31,7 @@ export function getUpdatesAvailableMessage( force: boolean ) { const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo; - const { needsRootInstall, needsWebInstall, needsMigrations } = requirements; + const { needsRootInstall, needsWebInstall, needsWebBuild, needsMigrations } = requirements; // Build commit list (max 5) const commitList = commits @@ -50,6 +50,7 @@ export function getUpdatesAvailableMessage( const reqs: string[] = []; if (needsRootInstall) reqs.push("📦 Install root dependencies"); if (needsWebInstall) reqs.push("🌐 Install web dependencies"); + if (needsWebBuild) reqs.push("🏗️ Build web dashboard"); if (needsMigrations) reqs.push("🗃️ Run database migrations"); if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)"); @@ -124,6 +125,9 @@ export function getUpdatingEmbed(requirements: UpdateCheckResult) { if (requirements.needsRootInstall || requirements.needsWebInstall) { steps.push("📦 Dependencies will be installed after restart"); } + if (requirements.needsWebBuild) { + steps.push("🏗️ Web dashboard will be rebuilt after restart"); + } if (requirements.needsMigrations) { steps.push("🗃️ Migrations will run after restart"); } @@ -157,16 +161,19 @@ export function getErrorEmbed(error: unknown) { export interface PostRestartResult { installSuccess: boolean; installOutput: string; + webBuildSuccess: boolean; + webBuildOutput: string; migrationSuccess: boolean; migrationOutput: string; ranInstall: boolean; + ranWebBuild: boolean; ranMigrations: boolean; previousCommit?: string; newCommit?: string; } export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) { - const isSuccess = result.installSuccess && result.migrationSuccess; + const isSuccess = result.installSuccess && result.webBuildSuccess && result.migrationSuccess; const embed = new EmbedBuilder() .setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues") @@ -192,6 +199,13 @@ export function getPostRestartEmbed(result: PostRestartResult, hasRollback: bool ); } + if (result.ranWebBuild) { + results.push(result.webBuildSuccess + ? "✅ Web dashboard built" + : "❌ Web dashboard build failed" + ); + } + if (result.ranMigrations) { results.push(result.migrationSuccess ? "✅ Migrations applied" @@ -216,6 +230,14 @@ export function getPostRestartEmbed(result: PostRestartResult, hasRollback: bool }); } + if (result.webBuildOutput && !result.webBuildSuccess) { + embed.addFields({ + name: "Web Build Output", + value: `\`\`\`\n${truncate(result.webBuildOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``, + inline: false + }); + } + if (result.migrationOutput && !result.migrationSuccess) { embed.addFields({ name: "Migration Output", @@ -259,6 +281,66 @@ export function getRunningMigrationsEmbed() { ); } +export function getBuildingWebEmbed() { + return createInfoEmbed( + "🌐 Building web dashboard assets...\nThis may take a moment.", + "⏳ Building Web Dashboard" + ); +} + +export interface PostRestartProgress { + installDeps: boolean; + buildWeb: boolean; + runMigrations: boolean; + currentStep: "starting" | "install" | "build" | "migrate" | "done"; + installDone?: boolean; + buildDone?: boolean; + migrateDone?: boolean; +} + +export function getPostRestartProgressEmbed(progress: PostRestartProgress) { + const steps: string[] = []; + + // Installation step + if (progress.installDeps) { + if (progress.currentStep === "install") { + steps.push("⏳ Installing dependencies..."); + } else if (progress.installDone) { + steps.push("✅ Dependencies installed"); + } else { + steps.push("⬚ Install dependencies"); + } + } + + // Web build step + if (progress.buildWeb) { + if (progress.currentStep === "build") { + steps.push("⏳ Building web dashboard..."); + } else if (progress.buildDone) { + steps.push("✅ Web dashboard built"); + } else { + steps.push("⬚ Build web dashboard"); + } + } + + // Migrations step + if (progress.runMigrations) { + if (progress.currentStep === "migrate") { + steps.push("⏳ Running migrations..."); + } else if (progress.migrateDone) { + steps.push("✅ Migrations applied"); + } else { + steps.push("⬚ Run migrations"); + } + } + + if (steps.length === 0) { + steps.push("⚡ Quick restart (no extra steps needed)"); + } + + return createInfoEmbed(steps.join("\n"), "🔄 Post-Update Tasks"); +} + export function getRollbackSuccessEmbed(commit: string) { return createSuccessEmbed( `Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`, diff --git a/bot/modules/economy/lootdrop.interaction.ts b/bot/modules/economy/lootdrop.interaction.ts index 72f1893..fae70c3 100644 --- a/bot/modules/economy/lootdrop.interaction.ts +++ b/bot/modules/economy/lootdrop.interaction.ts @@ -1,6 +1,6 @@ import { ButtonInteraction } from "discord.js"; import { lootdropService } from "@shared/modules/economy/lootdrop.service"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; import { getLootdropClaimedMessage } from "./lootdrop.view"; export async function handleLootdropInteraction(interaction: ButtonInteraction) { diff --git a/bot/modules/economy/shop.interaction.ts b/bot/modules/economy/shop.interaction.ts index 30b470e..0b397ae 100644 --- a/bot/modules/economy/shop.interaction.ts +++ b/bot/modules/economy/shop.interaction.ts @@ -1,7 +1,7 @@ import { ButtonInteraction, MessageFlags } from "discord.js"; import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { userService } from "@shared/modules/user/user.service"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; export async function handleShopInteraction(interaction: ButtonInteraction) { if (!interaction.customId.startsWith("shop_buy_")) return; diff --git a/bot/modules/feedback/feedback.interaction.ts b/bot/modules/feedback/feedback.interaction.ts index 4ccfc7e..4c67d6d 100644 --- a/bot/modules/feedback/feedback.interaction.ts +++ b/bot/modules/feedback/feedback.interaction.ts @@ -4,7 +4,7 @@ import { config } from "@shared/lib/config"; import { AuroraClient } from "@/lib/BotClient"; import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view"; import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; export const handleFeedbackInteraction = async (interaction: Interaction) => { // Handle select menu for choosing feedback type diff --git a/bot/modules/quest/quest.view.ts b/bot/modules/quest/quest.view.ts index e090c8e..2e18283 100644 --- a/bot/modules/quest/quest.view.ts +++ b/bot/modules/quest/quest.view.ts @@ -1,4 +1,13 @@ -import { EmbedBuilder } from "discord.js"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ContainerBuilder, + TextDisplayBuilder, + SeparatorBuilder, + SeparatorSpacingSize, + MessageFlags +} from "discord.js"; /** * Quest entry with quest details and progress @@ -7,12 +16,33 @@ interface QuestEntry { progress: number | null; completedAt: Date | null; quest: { + id: number; name: string; description: string | null; + triggerEvent: string; + requirements: any; rewards: any; }; } +/** + * Available quest interface + */ +interface AvailableQuest { + id: number; + name: string; + description: string | null; + rewards: any; + requirements: any; +} + +// Color palette for containers +const COLORS = { + ACTIVE: 0x3498db, // Blue - in progress + AVAILABLE: 0x2ecc71, // Green - available + COMPLETED: 0xf1c40f // Gold - completed +}; + /** * Formats quest rewards object into a human-readable string */ @@ -20,35 +50,169 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string const rewardStr: string[] = []; if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`); if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`); - return rewardStr.join(", "); + return rewardStr.join(" • ") || "None"; } /** - * Returns the quest status display string + * Renders a simple progress bar */ -function getQuestStatus(completedAt: Date | null): string { - return completedAt ? "✅ Completed" : "📝 In Progress"; +function renderProgressBar(current: number, total: number, size: number = 10): string { + const percentage = Math.min(current / total, 1); + const progress = Math.round(size * percentage); + const empty = size - progress; + + const progressText = "▰".repeat(progress); + const emptyText = "▱".repeat(empty); + + return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`; } /** - * Creates an embed displaying a user's quest log + * Creates Components v2 containers for the quest list (active quests only) */ -export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder { - const embed = new EmbedBuilder() - .setTitle("📜 Quest Log") - .setColor(0x3498db); // Blue +export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] { + // Filter to only show in-progress quests (not completed) + const activeQuests = userQuests.filter(entry => entry.completedAt === null); + + const container = new ContainerBuilder() + .setAccentColor(COLORS.ACTIVE) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("# 📜 Quest Log"), + new TextDisplayBuilder().setContent("-# Your active quests") + ); + + if (activeQuests.length === 0) { + container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*") + ); + return [container]; + } + + activeQuests.forEach((entry) => { + container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); - userQuests.forEach(entry => { - const status = getQuestStatus(entry.completedAt); const rewards = entry.quest.rewards as { xp?: number, balance?: number }; const rewardsText = formatQuestRewards(rewards); - embed.addFields({ - name: `${entry.quest.name} (${status})`, - value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`, - inline: false - }); + const requirements = entry.quest.requirements as { target?: number }; + const target = requirements?.target || 1; + const progress = entry.progress || 0; + const progressBar = renderProgressBar(progress, target); + + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`**${entry.quest.name}**`), + new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"), + new TextDisplayBuilder().setContent(`📊 ${progressBar} \`${progress}/${target}\` • 🎁 ${rewardsText}`) + ); }); - return embed; + return [container]; +} + +/** + * Creates Components v2 containers for available quests with inline accept buttons + */ +export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] { + const container = new ContainerBuilder() + .setAccentColor(COLORS.AVAILABLE) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("# 🗺️ Available Quests"), + new TextDisplayBuilder().setContent("-# Quests you can accept") + ); + + if (availableQuests.length === 0) { + container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent("*No new quests available at the moment.*") + ); + return [container]; + } + + // Limit to 10 quests (5 action rows max with 2 added for navigation) + const questsToShow = availableQuests.slice(0, 10); + + questsToShow.forEach((quest) => { + container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); + + const rewards = quest.rewards as { xp?: number, balance?: number }; + const rewardsText = formatQuestRewards(rewards); + + const requirements = quest.requirements as { target?: number }; + const target = requirements?.target || 1; + + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`**${quest.name}**`), + new TextDisplayBuilder().setContent(quest.description || "*No description*"), + new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` • 🎁 ${rewardsText}`) + ); + + // Add accept button inline within the container + container.addActionRowComponents( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`quest_accept:${quest.id}`) + .setLabel("Accept Quest") + .setStyle(ButtonStyle.Success) + .setEmoji("✅") + ) + ); + }); + + return [container]; +} + +/** + * Returns action rows for navigation only + */ +export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder[] { + // Navigation row + const navRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("quest_view_active") + .setLabel("📜 Active") + .setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setDisabled(viewType === 'active'), + new ButtonBuilder() + .setCustomId("quest_view_available") + .setLabel("🗺️ Available") + .setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setDisabled(viewType === 'available') + ); + + return [navRow]; +} + +/** + * Creates Components v2 celebratory message for quest completion + */ +export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] { + const rewardsText = formatQuestRewards({ + xp: Number(rewards.xp), + balance: Number(rewards.balance) + }); + + const container = new ContainerBuilder() + .setAccentColor(COLORS.COMPLETED) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("# 🎉 Quest Completed!"), + new TextDisplayBuilder().setContent(`Congratulations! You've completed **${quest.name}**`) + ) + .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`📝 ${quest.description || "No description provided."}`), + new TextDisplayBuilder().setContent(`🎁 **Rewards Earned:** ${rewardsText}`) + ); + + return [container]; +} + +/** + * Gets MessageFlags and allowedMentions for Components v2 messages + */ +export function getComponentsV2MessageFlags() { + return { + flags: MessageFlags.IsComponentsV2, + allowedMentions: { parse: [] as const } + }; } diff --git a/bot/modules/trade/trade.interaction.ts b/bot/modules/trade/trade.interaction.ts index ea4a45b..acdc537 100644 --- a/bot/modules/trade/trade.interaction.ts +++ b/bot/modules/trade/trade.interaction.ts @@ -10,7 +10,7 @@ import { import { tradeService } from "@shared/modules/trade/trade.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds"; -import { UserError } from "@lib/errors"; +import { UserError } from "@shared/lib/errors"; import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view"; diff --git a/bot/modules/trivia/trivia.interaction.ts b/bot/modules/trivia/trivia.interaction.ts index 5bf5006..69de286 100644 --- a/bot/modules/trivia/trivia.interaction.ts +++ b/bot/modules/trivia/trivia.interaction.ts @@ -1,7 +1,7 @@ import { ButtonInteraction } from "discord.js"; import { triviaService } from "@shared/modules/trivia/trivia.service"; import { getTriviaResultView, getTriviaTimeoutView } from "./trivia.view"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; export async function handleTriviaInteraction(interaction: ButtonInteraction) { const parts = interaction.customId.split('_'); diff --git a/bot/modules/user/enrollment.interaction.ts b/bot/modules/user/enrollment.interaction.ts index b15b0aa..bec444a 100644 --- a/bot/modules/user/enrollment.interaction.ts +++ b/bot/modules/user/enrollment.interaction.ts @@ -3,7 +3,7 @@ import { config } from "@shared/lib/config"; import { getEnrollmentSuccessMessage } from "./enrollment.view"; import { classService } from "@shared/modules/class/class.service"; import { userService } from "@shared/modules/user/user.service"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; import { sendWebhookMessage } from "@/lib/webhookUtils"; export async function handleEnrollmentInteraction(interaction: ButtonInteraction) { diff --git a/bun.lock b/bun.lock index f46e892..2fba0dd 100644 --- a/bun.lock +++ b/bun.lock @@ -9,12 +9,12 @@ "discord.js": "^14.25.1", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", + "postgres": "^3.4.7", "zod": "^4.1.13", }, "devDependencies": { "@types/bun": "latest", "drizzle-kit": "^0.31.7", - "postgres": "^3.4.7", }, "peerDependencies": { "typescript": "^5", diff --git a/docker-compose.yml b/docker-compose.yml index ef88aeb..e29b8c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,8 @@ services: # ports: # - "127.0.0.1:${DB_PORT}:5432" volumes: + # Host-mounted to preserve existing VPS data - ./shared/db/data:/var/lib/postgresql/data - - ./shared/db/log:/var/log/postgresql networks: - internal healthcheck: @@ -23,17 +23,19 @@ services: app: container_name: aurora_app restart: unless-stopped - image: aurora-app build: context: . dockerfile: Dockerfile + target: development # Use development stage working_dir: /app ports: - "127.0.0.1:3000:3000" volumes: + # Mount source code for hot reloading - .:/app - - /app/node_modules - - /app/web/node_modules + # Use named volumes for node_modules (prevents host overwrite + caches deps) + - app_node_modules:/app/node_modules + - web_node_modules:/app/web/node_modules environment: - HOST=0.0.0.0 - DB_USER=${DB_USER} @@ -61,30 +63,22 @@ services: studio: container_name: aurora_studio - image: aurora-app - build: - context: . - dockerfile: Dockerfile - working_dir: /app + # Reuse the same built image as app (no duplicate builds!) + extends: + service: app + # Clear inherited ports from app and only expose studio port ports: + !override - "127.0.0.1:4983:4983" - volumes: - - .:/app - - /app/node_modules - - /app/web/node_modules - environment: - - DB_USER=${DB_USER} - - DB_PASSWORD=${DB_PASSWORD} - - DB_NAME=${DB_NAME} - - DB_PORT=5432 - - DB_HOST=db - - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} - depends_on: - db: - condition: service_healthy - networks: - - internal - - web + # Override healthcheck since studio doesn't serve on port 3000 + healthcheck: + test: [ "CMD", "bun", "-e", "fetch('http://localhost:4983').then(r => process.exit(0)).catch(() => process.exit(1))" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + # Disable restart for studio (it's an on-demand tool) + restart: "no" command: [ "bun", "x", "drizzle-kit", "studio", "--port", "4983", "--host", "0.0.0.0" ] networks: @@ -93,3 +87,10 @@ networks: internal: true # No external access web: driver: bridge # Can be accessed from host + +volumes: + # Named volumes for node_modules caching + app_node_modules: + name: aurora_app_node_modules + web_node_modules: + name: aurora_web_node_modules diff --git a/docs/main.md b/docs/main.md new file mode 100644 index 0000000..d702d54 --- /dev/null +++ b/docs/main.md @@ -0,0 +1,168 @@ +# Aurora - Discord RPG Bot + +A comprehensive, feature-rich Discord RPG bot built with modern technologies using a monorepo architecture. + +## Architecture Overview + +Aurora uses a **Single Process Monolith** architecture that runs both the Discord bot and web dashboard in the same Bun process. This design maximizes performance by eliminating inter-process communication overhead and simplifies deployment to a single Docker container. + +## Monorepo Structure + +``` +aurora-bot-discord/ +├── bot/ # Discord bot implementation +│ ├── commands/ # Slash command implementations +│ ├── events/ # Discord event handlers +│ ├── lib/ # Bot core logic (BotClient, utilities) +│ └── index.ts # Bot entry point +├── web/ # React web dashboard +│ ├── src/ # React components and pages +│ │ ├── pages/ # Dashboard pages (Admin, Settings, Home) +│ │ ├── components/ # Reusable UI components +│ │ └── server.ts # Web server with API endpoints +│ └── build.ts # Vite build configuration +├── shared/ # Shared code between bot and web +│ ├── db/ # Database schema and Drizzle ORM +│ ├── lib/ # Utilities, config, logger, events +│ ├── modules/ # Domain services (economy, admin, quest) +│ └── config/ # Configuration files +├── docker-compose.yml # Docker services (app, db) +└── package.json # Root package manifest +``` + +## Main Application Parts + +### 1. Discord Bot (`bot/`) + +The bot is built with Discord.js v14 and handles all Discord-related functionality. + +**Core Components:** + +- **BotClient** (`bot/lib/BotClient.ts`): Central client that manages commands, events, and Discord interactions +- **Commands** (`bot/commands/`): Slash command implementations organized by category: + - `admin/`: Server management commands (config, prune, warnings, notes) + - `economy/`: Economy commands (balance, daily, pay, trade, trivia) + - `inventory/`: Item management commands + - `leveling/`: XP and level tracking + - `quest/`: Quest commands + - `user/`: User profile commands +- **Events** (`bot/events/`): Discord event handlers: + - `interactionCreate.ts`: Command interactions + - `messageCreate.ts`: Message processing + - `ready.ts`: Bot ready events + - `guildMemberAdd.ts`: New member handling + +### 2. Web Dashboard (`web/`) + +A React 19 + Bun web application for bot administration and monitoring. + +**Key Pages:** + +- **Home** (`/`): Dashboard overview with live statistics +- **Admin Overview** (`/admin/overview`): Real-time bot metrics +- **Admin Quests** (`/admin/quests`): Quest management interface +- **Settings** (`/settings/*`): Configuration pages for: + - General settings + - Economy settings + - Systems settings + - Roles settings + +**Web Server Features:** + +- Built with Bun's native HTTP server +- WebSocket support for real-time updates +- REST API endpoints for dashboard data +- SPA fallback for client-side routing +- Bun dev server with hot module replacement + +### 3. Shared Core (`shared/`) + +Shared code accessible by both bot and web applications. + +**Database Layer (`shared/db/`):** + +- **schema.ts**: Drizzle ORM schema definitions for: + - `users`: User profiles with economy data + - `items`: Item catalog with rarities and types + - `inventory`: User item holdings + - `transactions`: Economy transaction history + - `classes`: RPG class system + - `moderationCases`: Moderation logs + - `quests`: Quest definitions + +**Modules (`shared/modules/`):** + +- **economy/**: Economy service, lootdrops, daily rewards, trading +- **admin/**: Administrative actions (maintenance mode, cache clearing) +- **quest/**: Quest creation and tracking +- **dashboard/**: Dashboard statistics and real-time event bus +- **leveling/**: XP and leveling logic + +**Utilities (`shared/lib/`):** + +- `config.ts`: Application configuration management +- `logger.ts`: Structured logging system +- `env.ts`: Environment variable handling +- `events.ts`: Event bus for inter-module communication +- `constants.ts`: Application-wide constants + +## Main Use-Cases + +### For Discord Users + +1. **Class System**: Users can join different RPG classes with unique roles +2. **Economy**: + - View balance and net worth + - Earn currency through daily rewards, trivia, and lootdrops + - Send payments to other users +3. **Trading**: Secure trading system between users +4. **Inventory Management**: Collect, use, and trade items with rarities +5. **Leveling**: XP-based progression system tied to activity +6. **Quests**: Complete quests for rewards +7. **Lootdrops**: Random currency drops in text channels + +### For Server Administrators + +1. **Bot Configuration**: Adjust economy rates, enable/disable features via dashboard +2. **Moderation Tools**: + - Warn, note, and track moderation cases + - Mass prune inactive members + - Role management +3. **Quest Management**: Create and manage server-specific quests +4. **Monitoring**: + - Real-time dashboard with live statistics + - Activity charts and event logs + - Economy leaderboards + +### For Developers + +1. **Single Process Architecture**: Easy debugging with unified runtime +2. **Type Safety**: Full TypeScript across all modules +3. **Testing**: Bun test framework with unit tests for core services +4. **Docker Support**: Production-ready containerization +5. **Remote Access**: SSH tunneling scripts for production debugging + +## Technology Stack + +| Layer | Technology | +| ---------------- | --------------------------------- | +| Runtime | Bun 1.0+ | +| Bot Framework | Discord.js 14.x | +| Web Framework | React 19 + Bun | +| Database | PostgreSQL 17 | +| ORM | Drizzle ORM | +| Styling | Tailwind CSS v4 + ShadCN/Radix UI | +| Validation | Zod | +| Containerization | Docker | + +## Running the Application + +```bash +# Database migrations +bun run migrate + +# Production (Docker) +docker compose up +``` + +The bot and dashboard process run on port 3000 and are accessible at `http://localhost:3000`. diff --git a/package.json b/package.json index 95cceac..6b53462 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "app", + "version": "1.1.3", "module": "bot/index.ts", "type": "module", "private": true, @@ -20,7 +21,8 @@ "studio:remote": "bash shared/scripts/remote-studio.sh", "dashboard:remote": "bash shared/scripts/remote-dashboard.sh", "remote": "bash shared/scripts/remote.sh", - "test": "bun test" + "test": "bun test", + "docker:cleanup": "bash shared/scripts/docker-cleanup.sh" }, "dependencies": { "@napi-rs/canvas": "^0.1.84", diff --git a/shared/lib/constants.ts b/shared/lib/constants.ts index 3d85bad..653d187 100644 --- a/shared/lib/constants.ts +++ b/shared/lib/constants.ts @@ -85,3 +85,9 @@ export enum TriviaCategory { ANIMALS = 27, ANIME_MANGA = 31, } + +export const BRANDING = { + COLOR: 0x00d4ff as const, + FOOTER_TEXT: 'Aurora' as const, +}; + diff --git a/shared/lib/events.ts b/shared/lib/events.ts index aff8748..258c13a 100644 --- a/shared/lib/events.ts +++ b/shared/lib/events.ts @@ -17,5 +17,8 @@ export const EVENTS = { RELOAD_COMMANDS: "actions:reload_commands", CLEAR_CACHE: "actions:clear_cache", MAINTENANCE_MODE: "actions:maintenance_mode", + }, + QUEST: { + COMPLETED: "quest:completed", } } as const; diff --git a/shared/lib/logger.test.ts b/shared/lib/logger.test.ts new file mode 100644 index 0000000..71ce86b --- /dev/null +++ b/shared/lib/logger.test.ts @@ -0,0 +1,118 @@ +import { expect, test, describe, beforeAll, afterAll, spyOn } from "bun:test"; +import { logger } from "./logger"; +import { existsSync, unlinkSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; + +describe("Logger", () => { + const logDir = join(process.cwd(), "logs"); + const logFile = join(logDir, "error.log"); + + beforeAll(() => { + // Cleanup if exists + try { + if (existsSync(logFile)) unlinkSync(logFile); + } catch (e) {} + }); + + test("should log info messages to console with correct format", () => { + const spy = spyOn(console, "log"); + const message = "Formatting test"; + logger.info("system", message); + + expect(spy).toHaveBeenCalled(); + const callArgs = spy.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + if (callArgs) { + // Strict regex check for ISO timestamp and format + const regex = /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[INFO\] \[SYSTEM\] Formatting test$/; + expect(callArgs).toMatch(regex); + } + spy.mockRestore(); + }); + + test("should write error logs to file with stack trace", async () => { + const errorMessage = "Test error message"; + const testError = new Error("Source error"); + + logger.error("system", errorMessage, testError); + + // Polling for file write instead of fixed timeout + let content = ""; + for (let i = 0; i < 20; i++) { + if (existsSync(logFile)) { + content = readFileSync(logFile, "utf-8"); + if (content.includes("Source error")) break; + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + + expect(content).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[ERROR\] \[SYSTEM\] Test error message: Source error/); + expect(content).toContain("Stack Trace:"); + expect(content).toContain("Error: Source error"); + expect(content).toContain("logger.test.ts"); + }); + + test("should handle log directory creation failures gracefully", async () => { + const consoleSpy = spyOn(console, "error"); + + // We trigger an error by trying to use a path that is a file where a directory should be + const triggerFile = join(process.cwd(), "logs_fail_trigger"); + + try { + writeFileSync(triggerFile, "not a directory"); + + // Manually override paths for this test instance + const originalLogDir = (logger as any).logDir; + const originalLogPath = (logger as any).errorLogPath; + + (logger as any).logDir = triggerFile; + (logger as any).errorLogPath = join(triggerFile, "error.log"); + (logger as any).initialized = false; + + logger.error("system", "This should fail directory creation"); + + // Wait for async initialization attempt + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy.mock.calls.some(call => + String(call[0]).includes("Failed to initialize logger directory") + )).toBe(true); + + // Reset logger state + (logger as any).logDir = originalLogDir; + (logger as any).errorLogPath = originalLogPath; + (logger as any).initialized = false; + } finally { + if (existsSync(triggerFile)) unlinkSync(triggerFile); + consoleSpy.mockRestore(); + } + }); + + test("should include complex data objects in logs", () => { + const spy = spyOn(console, "log"); + const data = { userId: "123", tags: ["test"] }; + logger.info("bot", "Message with data", data); + + expect(spy).toHaveBeenCalled(); + const callArgs = spy.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + if (callArgs) { + expect(callArgs).toContain(` | Data: ${JSON.stringify(data)}`); + } + spy.mockRestore(); + }); + + test("should handle circular references in data objects", () => { + const spy = spyOn(console, "log"); + const data: any = { name: "circular" }; + data.self = data; + + logger.info("bot", "Circular test", data); + + expect(spy).toHaveBeenCalled(); + const callArgs = spy.mock.calls[0]?.[0]; + expect(callArgs).toContain("[Circular]"); + spy.mockRestore(); + }); +}); diff --git a/shared/lib/logger.ts b/shared/lib/logger.ts new file mode 100644 index 0000000..ed0cf61 --- /dev/null +++ b/shared/lib/logger.ts @@ -0,0 +1,162 @@ +import { join, resolve } from "path"; +import { appendFile, mkdir, stat } from "fs/promises"; +import { existsSync } from "fs"; + +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, +} + +const LogLevelNames = { + [LogLevel.DEBUG]: "DEBUG", + [LogLevel.INFO]: "INFO", + [LogLevel.WARN]: "WARN", + [LogLevel.ERROR]: "ERROR", +}; + +export type LogSource = "bot" | "web" | "shared" | "system"; + +export interface LogEntry { + timestamp: string; + level: string; + source: LogSource; + message: string; + data?: any; + stack?: string; +} + +class Logger { + private logDir: string; + private errorLogPath: string; + private initialized: boolean = false; + private initPromise: Promise | null = null; + + constructor() { + // Use resolve with __dirname or process.cwd() but make it more robust + // Since this is in shared/lib/, we can try to find the project root + // For now, let's stick to a resolved path from process.cwd() or a safer alternative + this.logDir = resolve(process.cwd(), "logs"); + this.errorLogPath = join(this.logDir, "error.log"); + } + + private async ensureInitialized() { + if (this.initialized) return; + if (this.initPromise) return this.initPromise; + + this.initPromise = (async () => { + try { + await mkdir(this.logDir, { recursive: true }); + this.initialized = true; + } catch (err: any) { + if (err.code === "EEXIST" || err.code === "ENOTDIR") { + try { + const stats = await stat(this.logDir); + if (stats.isDirectory()) { + this.initialized = true; + return; + } + } catch (statErr) {} + } + console.error(`[SYSTEM] Failed to initialize logger directory at ${this.logDir}:`, err); + } finally { + this.initPromise = null; + } + })(); + + return this.initPromise; + } + + private safeStringify(data: any): string { + try { + return JSON.stringify(data); + } catch (err) { + const seen = new WeakSet(); + return JSON.stringify(data, (key, value) => { + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + } + return value; + }); + } + } + + private formatMessage(entry: LogEntry): string { + const dataStr = entry.data ? ` | Data: ${this.safeStringify(entry.data)}` : ""; + const stackStr = entry.stack ? `\nStack Trace:\n${entry.stack}` : ""; + return `[${entry.timestamp}] [${entry.level}] [${entry.source.toUpperCase()}] ${entry.message}${dataStr}${stackStr}`; + } + + private async writeToErrorLog(formatted: string) { + await this.ensureInitialized(); + try { + await appendFile(this.errorLogPath, formatted + "\n"); + } catch (err) { + console.error("[SYSTEM] Failed to write to error log file:", err); + } + } + + private log(level: LogLevel, source: LogSource, message: string, errorOrData?: any) { + const timestamp = new Date().toISOString(); + const levelName = LogLevelNames[level]; + + const entry: LogEntry = { + timestamp, + level: levelName, + source, + message, + }; + + if (level === LogLevel.ERROR && errorOrData instanceof Error) { + entry.stack = errorOrData.stack; + entry.message = `${message}: ${errorOrData.message}`; + } else if (errorOrData !== undefined) { + entry.data = errorOrData; + } + + const formatted = this.formatMessage(entry); + + // Print to console + switch (level) { + case LogLevel.DEBUG: + console.debug(formatted); + break; + case LogLevel.INFO: + console.log(formatted); + break; + case LogLevel.WARN: + console.warn(formatted); + break; + case LogLevel.ERROR: + console.error(formatted); + break; + } + + // Persistent error logging + if (level === LogLevel.ERROR) { + this.writeToErrorLog(formatted).catch(() => { + // Silently fail to avoid infinite loops + }); + } + } + + debug(source: LogSource, message: string, data?: any) { + this.log(LogLevel.DEBUG, source, message, data); + } + + info(source: LogSource, message: string, data?: any) { + this.log(LogLevel.INFO, source, message, data); + } + + warn(source: LogSource, message: string, data?: any) { + this.log(LogLevel.WARN, source, message, data); + } + + error(source: LogSource, message: string, error?: any) { + this.log(LogLevel.ERROR, source, message, error); + } +} + +export const logger = new Logger(); diff --git a/shared/modules/admin/update.service.test.ts b/shared/modules/admin/update.service.test.ts index 7eb0d17..4373c52 100644 --- a/shared/modules/admin/update.service.test.ts +++ b/shared/modules/admin/update.service.test.ts @@ -1,5 +1,4 @@ -import { describe, expect, test, mock, beforeEach, afterAll, spyOn } from "bun:test"; -import * as fs from "fs/promises"; +import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test"; // Mock child_process BEFORE importing the service const mockExec = mock((cmd: string, callback?: any) => { @@ -8,23 +7,32 @@ const mockExec = mock((cmd: string, callback?: any) => { return { unref: () => { } }; } - if (cmd.includes("git rev-parse")) { - callback(null, { stdout: "main\n" }); + // Simulate successful command execution + let stdout = ""; + + if (cmd.includes("git rev-parse --abbrev-ref")) { + stdout = "main\n"; + } else if (cmd.includes("git rev-parse --short")) { + stdout = "abc1234\n"; + } else if (cmd.includes("git rev-parse HEAD")) { + stdout = "abc1234567890\n"; } else if (cmd.includes("git fetch")) { - callback(null, { stdout: "" }); + stdout = ""; } else if (cmd.includes("git log")) { - callback(null, { stdout: "abcdef Update 1\n123456 Update 2" }); + stdout = "abcdef|Update 1|Author1\n123456|Update 2|Author2"; } else if (cmd.includes("git diff")) { - callback(null, { stdout: "package.json\nsrc/index.ts" }); + stdout = "package.json\nsrc/index.ts\nshared/lib/schema.ts"; } else if (cmd.includes("git reset")) { - callback(null, { stdout: "HEAD is now at abcdef Update 1" }); + stdout = "HEAD is now at abcdef Update 1"; } else if (cmd.includes("bun install")) { - callback(null, { stdout: "Installed dependencies" }); + stdout = "Installed dependencies"; } else if (cmd.includes("drizzle-kit migrate")) { - callback(null, { stdout: "Migrations applied" }); - } else { - callback(null, { stdout: "" }); + stdout = "Migrations applied"; + } else if (cmd.includes("bun run build")) { + stdout = "Build completed"; } + + callback(null, stdout, ""); }); mock.module("child_process", () => ({ @@ -32,9 +40,9 @@ mock.module("child_process", () => ({ })); // Mock fs/promises -const mockWriteFile = mock((path: string, content: string) => Promise.resolve()); -const mockReadFile = mock((path: string, encoding: string) => Promise.resolve("{}")); -const mockUnlink = mock((path: string) => Promise.resolve()); +const mockWriteFile = mock((_path: string, _content: string) => Promise.resolve()); +const mockReadFile = mock((_path: string, _encoding: string) => Promise.resolve("{}")); +const mockUnlink = mock((_path: string) => Promise.resolve()); mock.module("fs/promises", () => ({ writeFile: mockWriteFile, @@ -43,9 +51,9 @@ mock.module("fs/promises", () => ({ })); // Mock view module to avoid import issues -mock.module("./update.view", () => ({ - getPostRestartEmbed: () => ({ title: "Update Complete" }), - getInstallingDependenciesEmbed: () => ({ title: "Installing..." }), +mock.module("@/modules/admin/update.view", () => ({ + getPostRestartEmbed: () => ({ embeds: [{ title: "Update Complete" }], components: [] }), + getPostRestartProgressEmbed: () => ({ title: "Progress..." }), })); describe("UpdateService", () => { @@ -72,7 +80,8 @@ describe("UpdateService", () => { expect(result.hasUpdates).toBe(true); expect(result.branch).toBe("main"); - expect(result.log).toContain("Update 1"); + expect(result.commits.length).toBeGreaterThan(0); + expect(result.commits[0].message).toContain("Update 1"); }); test("should call git rev-parse, fetch, and log commands", async () => { @@ -83,43 +92,82 @@ describe("UpdateService", () => { expect(calls.some((cmd: string) => cmd.includes("git fetch"))).toBe(true); expect(calls.some((cmd: string) => cmd.includes("git log"))).toBe(true); }); + + test("should include requirements in the response", async () => { + const result = await UpdateService.checkForUpdates(); + + expect(result.requirements).toBeDefined(); + expect(result.requirements.needsRootInstall).toBe(true); // package.json is in mock + expect(result.requirements.needsMigrations).toBe(true); // schema.ts is in mock + expect(result.requirements.changedFiles).toContain("package.json"); + }); }); describe("performUpdate", () => { test("should run git reset --hard with correct branch", async () => { await UpdateService.performUpdate("main"); - const lastCall = mockExec.mock.lastCall; - expect(lastCall).toBeDefined(); - expect(lastCall![0]).toContain("git reset --hard origin/main"); + const calls = mockExec.mock.calls.map((c: any) => c[0]); + expect(calls.some((cmd: string) => cmd.includes("git reset --hard origin/main"))).toBe(true); }); }); - describe("checkUpdateRequirements", () => { + describe("checkUpdateRequirements (deprecated)", () => { test("should detect package.json and schema.ts changes", async () => { const result = await UpdateService.checkUpdateRequirements("main"); - expect(result.needsInstall).toBe(true); - expect(result.needsMigrations).toBe(false); // mock doesn't include schema.ts + expect(result.needsRootInstall).toBe(true); + expect(result.needsMigrations).toBe(true); expect(result.error).toBeUndefined(); }); test("should call git diff with correct branch", async () => { await UpdateService.checkUpdateRequirements("develop"); - const lastCall = mockExec.mock.lastCall; - expect(lastCall).toBeDefined(); - expect(lastCall![0]).toContain("git diff HEAD..origin/develop"); + const calls = mockExec.mock.calls.map((c: any) => c[0]); + expect(calls.some((cmd: string) => cmd.includes("git diff HEAD..origin/develop"))).toBe(true); }); }); describe("installDependencies", () => { - test("should run bun install and return output", async () => { - const output = await UpdateService.installDependencies(); + test("should run bun install for root only", async () => { + const output = await UpdateService.installDependencies({ root: true, web: false }); - expect(output).toBe("Installed dependencies"); - const lastCall = mockExec.mock.lastCall; - expect(lastCall![0]).toBe("bun install"); + expect(output).toContain("Root"); + const calls = mockExec.mock.calls.map((c: any) => c[0]); + expect(calls.some((cmd: string) => cmd === "bun install")).toBe(true); + }); + + test("should run bun install for both root and web in parallel", async () => { + const output = await UpdateService.installDependencies({ root: true, web: true }); + + expect(output).toContain("Root"); + expect(output).toContain("Web"); + const calls = mockExec.mock.calls.map((c: any) => c[0]); + expect(calls.some((cmd: string) => cmd === "bun install")).toBe(true); + expect(calls.some((cmd: string) => cmd.includes("cd web && bun install"))).toBe(true); + }); + }); + + describe("categorizeChanges", () => { + test("should categorize files correctly", () => { + const files = [ + "bot/commands/admin/update.ts", + "bot/modules/admin/update.view.ts", + "web/src/components/Button.tsx", + "shared/lib/utils.ts", + "package.json", + "drizzle/0001_migration.sql" + ]; + + const categories = UpdateService.categorizeChanges(files); + + expect(categories["Commands"]).toBe(1); + expect(categories["Modules"]).toBe(1); + expect(categories["Web Dashboard"]).toBe(1); + expect(categories["Library"]).toBe(1); + expect(categories["Dependencies"]).toBe(1); + expect(categories["Database"]).toBe(1); }); }); @@ -130,7 +178,10 @@ describe("UpdateService", () => { userId: "456", timestamp: Date.now(), runMigrations: true, - installDependencies: false + installDependencies: false, + buildWebAssets: false, + previousCommit: "abc1234", + newCommit: "def5678" }; await UpdateService.prepareRestartContext(context); @@ -143,6 +194,39 @@ describe("UpdateService", () => { }); }); + describe("saveRollbackPoint", () => { + test("should save current commit hash to file", async () => { + const commit = await UpdateService.saveRollbackPoint(); + + expect(commit).toBeTruthy(); + expect(mockWriteFile).toHaveBeenCalled(); + const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined; + expect(lastCall![0]).toContain("rollback_commit"); + }); + }); + + describe("hasRollbackPoint", () => { + test("should return true when rollback file exists", async () => { + mockReadFile.mockImplementationOnce(() => Promise.resolve("abc123")); + + // Clear cache first + (UpdateService as any).rollbackPointExists = null; + + const result = await UpdateService.hasRollbackPoint(); + expect(result).toBe(true); + }); + + test("should return false when rollback file does not exist", async () => { + mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT"))); + + // Clear cache first + (UpdateService as any).rollbackPointExists = null; + + const result = await UpdateService.hasRollbackPoint(); + expect(result).toBe(false); + }); + }); + describe("triggerRestart", () => { test("should use RESTART_COMMAND env var when set", async () => { const originalEnv = process.env.RESTART_COMMAND; @@ -150,24 +234,19 @@ describe("UpdateService", () => { await UpdateService.triggerRestart(); - const lastCall = mockExec.mock.lastCall; - expect(lastCall).toBeDefined(); - expect(lastCall![0]).toBe("pm2 restart bot"); + const calls = mockExec.mock.calls.map((c: any) => c[0]); + expect(calls.some((cmd: string) => cmd === "pm2 restart bot")).toBe(true); process.env.RESTART_COMMAND = originalEnv; }); - test("should write to trigger file when no env var", async () => { + test("should call process.exit when no env var is set", async () => { const originalEnv = process.env.RESTART_COMMAND; delete process.env.RESTART_COMMAND; + // Just verify it doesn't throw - actual process.exit is mocked by setTimeout await UpdateService.triggerRestart(); - expect(mockWriteFile).toHaveBeenCalled(); - const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined; - expect(lastCall).toBeDefined(); - expect(lastCall![0]).toContain("restart_trigger"); - process.env.RESTART_COMMAND = originalEnv; }); }); @@ -181,7 +260,7 @@ describe("UpdateService", () => { const createMockChannel = () => ({ isSendable: () => true, - send: mock(() => Promise.resolve()) + send: mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) })) }); test("should ignore stale context (>10 mins old)", async () => { @@ -190,7 +269,10 @@ describe("UpdateService", () => { userId: "456", timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago runMigrations: true, - installDependencies: true + installDependencies: true, + buildWebAssets: false, + previousCommit: "abc", + newCommit: "def" }; mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext))); @@ -227,7 +309,10 @@ describe("UpdateService", () => { userId: "456", timestamp: Date.now(), runMigrations: false, - installDependencies: false + installDependencies: false, + buildWebAssets: false, + previousCommit: "abc", + newCommit: "def" }; mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext))); @@ -236,7 +321,7 @@ describe("UpdateService", () => { const { TextChannel } = await import("discord.js"); const mockChannel = Object.create(TextChannel.prototype); mockChannel.isSendable = () => true; - mockChannel.send = mock(() => Promise.resolve()); + mockChannel.send = mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) })); const mockClient = createMockClient(mockChannel); diff --git a/shared/modules/admin/update.service.ts b/shared/modules/admin/update.service.ts index 2b4f1c2..4de2175 100644 --- a/shared/modules/admin/update.service.ts +++ b/shared/modules/admin/update.service.ts @@ -1,8 +1,8 @@ -import { exec } from "child_process"; +import { exec, type ExecException } from "child_process"; import { promisify } from "util"; import { writeFile, readFile, unlink } from "fs/promises"; import { Client, TextChannel } from "discord.js"; -import { getPostRestartEmbed, getInstallingDependenciesEmbed, getRunningMigrationsEmbed } from "@/modules/admin/update.view"; +import { getPostRestartEmbed, getPostRestartProgressEmbed, type PostRestartProgress } from "@/modules/admin/update.view"; import type { PostRestartResult } from "@/modules/admin/update.view"; import type { RestartContext, UpdateCheckResult, UpdateInfo, CommitInfo } from "@/modules/admin/update.types"; @@ -10,32 +10,69 @@ const execAsync = promisify(exec); // Constants const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes +const DEFAULT_TIMEOUT_MS = 30_000; // 30 seconds for git commands +const INSTALL_TIMEOUT_MS = 120_000; // 2 minutes for dependency installation +const BUILD_TIMEOUT_MS = 180_000; // 3 minutes for web build +/** + * Execute a command with timeout protection + */ +async function execWithTimeout( + cmd: string, + timeoutMs: number = DEFAULT_TIMEOUT_MS +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const process = exec(cmd, (error: ExecException | null, stdout: string, stderr: string) => { + if (error) { + reject(error); + } else { + resolve({ stdout, stderr }); + } + }); + const timer = setTimeout(() => { + process.kill("SIGTERM"); + reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`)); + }, timeoutMs); + + process.on("exit", () => clearTimeout(timer)); + }); +} export class UpdateService { private static readonly CONTEXT_FILE = ".restart_context.json"; private static readonly ROLLBACK_FILE = ".rollback_commit.txt"; + // Cache for rollback state (set when we save, cleared on cleanup) + private static rollbackPointExists: boolean | null = null; + /** * Check for available updates with detailed commit information + * Optimized: Parallel git commands and combined requirements check */ - static async checkForUpdates(): Promise { - const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD"); + static async checkForUpdates(): Promise { + // Get branch first (needed for subsequent commands) + const { stdout: branchName } = await execWithTimeout("git rev-parse --abbrev-ref HEAD"); const branch = branchName.trim(); - const { stdout: currentCommit } = await execAsync("git rev-parse --short HEAD"); + // Parallel execution: get current commit while fetching + const [currentResult] = await Promise.all([ + execWithTimeout("git rev-parse --short HEAD"), + execWithTimeout(`git fetch origin ${branch} --prune`) // Only fetch current branch + ]); + const currentCommit = currentResult.stdout.trim(); - await execAsync("git fetch --all"); + // After fetch completes, get remote info in parallel + const [latestResult, logResult, diffResult] = await Promise.all([ + execWithTimeout(`git rev-parse --short origin/${branch}`), + execWithTimeout(`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`), + execWithTimeout(`git diff HEAD..origin/${branch} --name-only`) + ]); - const { stdout: latestCommit } = await execAsync(`git rev-parse --short origin/${branch}`); + const latestCommit = latestResult.stdout.trim(); - // Get commit log with author info - const { stdout: logOutput } = await execAsync( - `git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges` - ); - - const commits: CommitInfo[] = logOutput + // Parse commit log + const commits: CommitInfo[] = logResult.stdout .trim() .split("\n") .filter(line => line.length > 0) @@ -44,47 +81,70 @@ export class UpdateService { return { hash: hash || "", message: message || "", author: author || "" }; }); + // Parse changed files and analyze requirements in one pass + const changedFiles = diffResult.stdout.trim().split("\n").filter(f => f.length > 0); + const requirements = this.analyzeChangedFiles(changedFiles); + return { hasUpdates: commits.length > 0, branch, - currentCommit: currentCommit.trim(), - latestCommit: latestCommit.trim(), + currentCommit, + latestCommit, commitCount: commits.length, - commits + commits, + requirements }; } /** - * Analyze what the update requires + * Analyze changed files to determine update requirements + * Extracted for reuse and clarity + */ + private static analyzeChangedFiles(changedFiles: string[]): UpdateCheckResult { + const needsRootInstall = changedFiles.some(file => + file === "package.json" || file === "bun.lock" + ); + + const needsWebInstall = changedFiles.some(file => + file === "web/package.json" || file === "web/bun.lock" + ); + + // Only rebuild web if essential source files changed + const needsWebBuild = changedFiles.some(file => + file.match(/^web\/src\/(components|pages|lib|index)/) || + file === "web/build.ts" || + file === "web/tailwind.config.ts" || + file === "web/tsconfig.json" + ); + + const needsMigrations = changedFiles.some(file => + file.includes("schema.ts") || file.startsWith("drizzle/") + ); + + return { + needsRootInstall, + needsWebInstall, + needsWebBuild, + needsMigrations, + changedFiles + }; + } + + /** + * @deprecated Use checkForUpdates() which now includes requirements + * Kept for backwards compatibility */ static async checkUpdateRequirements(branch: string): Promise { try { - const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`); + const { stdout } = await execWithTimeout(`git diff HEAD..origin/${branch} --name-only`); const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0); - - const needsRootInstall = changedFiles.some(file => - file === "package.json" || file === "bun.lock" - ); - - const needsWebInstall = changedFiles.some(file => - file === "web/package.json" || file === "web/bun.lock" - ); - - const needsMigrations = changedFiles.some(file => - file.includes("schema.ts") || file.startsWith("drizzle/") - ); - - return { - needsRootInstall, - needsWebInstall, - needsMigrations, - changedFiles - }; + return this.analyzeChangedFiles(changedFiles); } catch (e) { console.error("Failed to check update requirements:", e); return { needsRootInstall: false, needsWebInstall: false, + needsWebBuild: false, needsMigrations: false, changedFiles: [], error: e instanceof Error ? e : new Error(String(e)) @@ -119,9 +179,10 @@ export class UpdateService { * Save the current commit for potential rollback */ static async saveRollbackPoint(): Promise { - const { stdout } = await execAsync("git rev-parse HEAD"); + const { stdout } = await execWithTimeout("git rev-parse HEAD"); const commit = stdout.trim(); await writeFile(this.ROLLBACK_FILE, commit); + this.rollbackPointExists = true; // Cache the state return commit; } @@ -131,8 +192,9 @@ export class UpdateService { static async rollback(): Promise<{ success: boolean; message: string }> { try { const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8"); - await execAsync(`git reset --hard ${rollbackCommit.trim()}`); + await execWithTimeout(`git reset --hard ${rollbackCommit.trim()}`); await unlink(this.ROLLBACK_FILE); + this.rollbackPointExists = false; return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` }; } catch (e) { return { @@ -144,12 +206,18 @@ export class UpdateService { /** * Check if a rollback point exists + * Uses cache when available to avoid file system access */ static async hasRollbackPoint(): Promise { + if (this.rollbackPointExists !== null) { + return this.rollbackPointExists; + } try { await readFile(this.ROLLBACK_FILE, "utf-8"); + this.rollbackPointExists = true; return true; } catch { + this.rollbackPointExists = false; return false; } } @@ -158,26 +226,32 @@ export class UpdateService { * Perform the git update */ static async performUpdate(branch: string): Promise { - await execAsync(`git reset --hard origin/${branch}`); + await execWithTimeout(`git reset --hard origin/${branch}`); } /** * Install dependencies for specified projects + * Optimized: Parallel installation */ static async installDependencies(options: { root: boolean; web: boolean }): Promise { - const outputs: string[] = []; + const tasks: Promise<{ label: string; output: string }>[] = []; if (options.root) { - const { stdout } = await execAsync("bun install"); - outputs.push(`📦 Root: ${stdout.trim() || "Done"}`); + tasks.push( + execWithTimeout("bun install", INSTALL_TIMEOUT_MS) + .then(({ stdout }) => ({ label: "📦 Root", output: stdout.trim() || "Done" })) + ); } if (options.web) { - const { stdout } = await execAsync("cd web && bun install"); - outputs.push(`🌐 Web: ${stdout.trim() || "Done"}`); + tasks.push( + execWithTimeout("cd web && bun install", INSTALL_TIMEOUT_MS) + .then(({ stdout }) => ({ label: "🌐 Web", output: stdout.trim() || "Done" })) + ); } - return outputs.join("\n"); + const results = await Promise.all(tasks); + return results.map(r => `${r.label}: ${r.output}`).join("\n"); } /** @@ -218,7 +292,7 @@ export class UpdateService { } const result = await this.executePostRestartTasks(context, channel); - await this.notifyPostRestartResult(channel, result, context); + await this.notifyPostRestartResult(channel, result); await this.cleanupContext(); } catch (e) { console.error("Failed to handle post-restart context:", e); @@ -259,51 +333,120 @@ export class UpdateService { const result: PostRestartResult = { installSuccess: true, installOutput: "", + webBuildSuccess: true, + webBuildOutput: "", migrationSuccess: true, migrationOutput: "", ranInstall: context.installDependencies, + ranWebBuild: context.buildWebAssets, ranMigrations: context.runMigrations, previousCommit: context.previousCommit, newCommit: context.newCommit }; - // 1. Install Dependencies if needed + // Track progress for consolidated message + const progress: PostRestartProgress = { + installDeps: context.installDependencies, + buildWeb: context.buildWebAssets, + runMigrations: context.runMigrations, + currentStep: "starting" + }; + + // Only send progress message if there are tasks to run + const hasTasks = context.installDependencies || context.buildWebAssets || context.runMigrations; + let progressMessage = hasTasks + ? await channel.send({ embeds: [getPostRestartProgressEmbed(progress)] }) + : null; + + // Helper to update progress message + const updateProgress = async () => { + if (progressMessage) { + await progressMessage.edit({ embeds: [getPostRestartProgressEmbed(progress)] }); + } + }; + + // 1. Install Dependencies if needed (PARALLELIZED) if (context.installDependencies) { try { - await channel.send({ embeds: [getInstallingDependenciesEmbed()] }); + progress.currentStep = "install"; + await updateProgress(); - const { stdout: rootOutput } = await execAsync("bun install"); - const { stdout: webOutput } = await execAsync("cd web && bun install"); + // Parallel installation of root and web dependencies + const [rootResult, webResult] = await Promise.all([ + execWithTimeout("bun install", INSTALL_TIMEOUT_MS) + .then(({ stdout }) => ({ success: true, output: stdout.trim() || "Done" })) + .catch(err => ({ success: false, output: err instanceof Error ? err.message : String(err) })), + execWithTimeout("cd web && bun install", INSTALL_TIMEOUT_MS) + .then(({ stdout }) => ({ success: true, output: stdout.trim() || "Done" })) + .catch(err => ({ success: false, output: err instanceof Error ? err.message : String(err) })) + ]); - result.installOutput = `📦 Root: ${rootOutput.trim() || "Done"}\n🌐 Web: ${webOutput.trim() || "Done"}`; + result.installSuccess = rootResult.success && webResult.success; + result.installOutput = `📦 Root: ${rootResult.output}\n🌐 Web: ${webResult.output}`; + progress.installDone = true; + + if (!result.installSuccess) { + console.error("Dependency Install Failed:", result.installOutput); + } } catch (err: unknown) { result.installSuccess = false; result.installOutput = err instanceof Error ? err.message : String(err); + progress.installDone = true; console.error("Dependency Install Failed:", err); } } - // 2. Run Migrations + // 2. Build Web Assets if needed + if (context.buildWebAssets) { + try { + progress.currentStep = "build"; + await updateProgress(); + + const { stdout } = await execWithTimeout("cd web && bun run build", BUILD_TIMEOUT_MS); + result.webBuildOutput = stdout.trim() || "Build completed successfully"; + progress.buildDone = true; + } catch (err: unknown) { + result.webBuildSuccess = false; + result.webBuildOutput = err instanceof Error ? err.message : String(err); + progress.buildDone = true; + console.error("Web Build Failed:", err); + } + } + + // 3. Run Migrations if (context.runMigrations) { try { - await channel.send({ embeds: [getRunningMigrationsEmbed()] }); - const { stdout } = await execAsync("bun x drizzle-kit migrate"); + progress.currentStep = "migrate"; + await updateProgress(); + + const { stdout } = await execWithTimeout("bun x drizzle-kit migrate", DEFAULT_TIMEOUT_MS); result.migrationOutput = stdout; + progress.migrateDone = true; } catch (err: unknown) { result.migrationSuccess = false; result.migrationOutput = err instanceof Error ? err.message : String(err); + progress.migrateDone = true; console.error("Migration Failed:", err); } } + // Delete progress message before final result + if (progressMessage) { + try { + await progressMessage.delete(); + } catch { + // Message may already be deleted, ignore + } + } + return result; } private static async notifyPostRestartResult( channel: TextChannel, - result: PostRestartResult, - context: RestartContext + result: PostRestartResult ): Promise { + // Use cached rollback state - we just saved it before restart const hasRollback = await this.hasRollbackPoint(); await channel.send(getPostRestartEmbed(result, hasRollback)); } @@ -314,5 +457,6 @@ export class UpdateService { } catch { // File may not exist, ignore } + // Don't clear rollback cache here - rollback file persists } } diff --git a/shared/modules/dashboard/dashboard.types.ts b/shared/modules/dashboard/dashboard.types.ts index 4dc3ce4..948e0c9 100644 --- a/shared/modules/dashboard/dashboard.types.ts +++ b/shared/modules/dashboard/dashboard.types.ts @@ -13,6 +13,7 @@ export const DashboardStatsSchema = z.object({ bot: z.object({ name: z.string(), avatarUrl: z.string().nullable(), + status: z.string().nullable(), }), guilds: z.object({ count: z.number(), @@ -84,6 +85,7 @@ export const ClientStatsSchema = z.object({ bot: z.object({ name: z.string(), avatarUrl: z.string().nullable(), + status: z.string().nullable(), }), guilds: z.number(), ping: z.number(), diff --git a/shared/modules/economy/economy.service.ts b/shared/modules/economy/economy.service.ts index b093401..f12eab0 100644 --- a/shared/modules/economy/economy.service.ts +++ b/shared/modules/economy/economy.service.ts @@ -87,7 +87,7 @@ export const economyService = { }); if (cooldown && cooldown.expiresAt > now) { - throw new UserError(`Daily already claimed today. Next claim `); + throw new UserError(`You have already claimed your daily reward today.\nNext claim available: ()`); } // Get user for streak logic @@ -196,6 +196,10 @@ export const economyService = { description: description, }); + // Trigger Quest Event + const { questService } = await import("@shared/modules/quest/quest.service"); + await questService.handleEvent(id, type, 1, txFn); + return user; }, tx); }, diff --git a/shared/modules/economy/exam.service.test.ts b/shared/modules/economy/exam.service.test.ts new file mode 100644 index 0000000..692955b --- /dev/null +++ b/shared/modules/economy/exam.service.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test"; +import { examService, ExamStatus } from "@shared/modules/economy/exam.service"; +import { users, userTimers, transactions } from "@db/schema"; + +// Define mock functions +const mockFindFirst = mock(); +const mockInsert = mock(); +const mockUpdate = mock(); +const mockValues = mock(); +const mockReturning = mock(); +const mockSet = mock(); +const mockWhere = mock(); + +// Chainable mock setup +mockInsert.mockReturnValue({ values: mockValues }); +mockValues.mockReturnValue({ returning: mockReturning }); +mockUpdate.mockReturnValue({ set: mockSet }); +mockSet.mockReturnValue({ where: mockWhere }); +mockWhere.mockReturnValue({ returning: mockReturning }); + +// Mock DrizzleClient +mock.module("@shared/db/DrizzleClient", () => { + const createMockTx = () => ({ + query: { + users: { findFirst: mockFindFirst }, + userTimers: { findFirst: mockFindFirst }, + }, + insert: mockInsert, + update: mockUpdate, + }); + + return { + DrizzleClient: { + query: { + users: { findFirst: mockFindFirst }, + userTimers: { findFirst: mockFindFirst }, + }, + insert: mockInsert, + update: mockUpdate, + transaction: async (cb: any) => { + return cb(createMockTx()); + } + }, + }; +}); + +// Mock withTransaction +mock.module("@/lib/db", () => ({ + withTransaction: async (cb: any, tx?: any) => { + if (tx) return cb(tx); + return cb({ + query: { + users: { findFirst: mockFindFirst }, + userTimers: { findFirst: mockFindFirst }, + }, + insert: mockInsert, + update: mockUpdate, + }); + } +})); + +// Mock Config +mock.module("@shared/lib/config", () => ({ + config: { + economy: { + exam: { + multMin: 1.0, + multMax: 2.0, + } + } + } +})); + +// Mock User Service +mock.module("@shared/modules/user/user.service", () => ({ + userService: { + getOrCreateUser: mock() + } +})); + +// Mock Dashboard Service +mock.module("@shared/modules/dashboard/dashboard.service", () => ({ + dashboardService: { + recordEvent: mock() + } +})); + +describe("ExamService", () => { + beforeEach(() => { + mockFindFirst.mockReset(); + mockInsert.mockClear(); + mockUpdate.mockClear(); + mockValues.mockClear(); + mockReturning.mockClear(); + mockSet.mockClear(); + mockWhere.mockClear(); + }); + + describe("getExamStatus", () => { + it("should return NOT_REGISTERED if no timer exists", async () => { + mockFindFirst.mockResolvedValue(undefined); + const status = await examService.getExamStatus("1"); + expect(status.status).toBe(ExamStatus.NOT_REGISTERED); + }); + + it("should return COOLDOWN if now < expiresAt", async () => { + const now = new Date("2024-01-10T12:00:00Z"); + setSystemTime(now); + const future = new Date("2024-01-11T00:00:00Z"); + + mockFindFirst.mockResolvedValue({ + expiresAt: future, + metadata: { examDay: 3, lastXp: "100" } + }); + + const status = await examService.getExamStatus("1"); + expect(status.status).toBe(ExamStatus.COOLDOWN); + expect(status.nextExamAt?.getTime()).toBe(future.setHours(0,0,0,0)); + }); + + it("should return MISSED if it is the wrong day", async () => { + const now = new Date("2024-01-15T12:00:00Z"); // Monday (1) + setSystemTime(now); + const past = new Date("2024-01-10T00:00:00Z"); // Wednesday (3) last week + + mockFindFirst.mockResolvedValue({ + expiresAt: past, + metadata: { examDay: 3, lastXp: "100" } // Registered for Wednesday + }); + + const status = await examService.getExamStatus("1"); + expect(status.status).toBe(ExamStatus.MISSED); + expect(status.examDay).toBe(3); + }); + + it("should return AVAILABLE if it is the correct day", async () => { + const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3) + setSystemTime(now); + const past = new Date("2024-01-10T00:00:00Z"); + + mockFindFirst.mockResolvedValue({ + expiresAt: past, + metadata: { examDay: 3, lastXp: "100" } + }); + + const status = await examService.getExamStatus("1"); + expect(status.status).toBe(ExamStatus.AVAILABLE); + expect(status.examDay).toBe(3); + expect(status.lastXp).toBe(100n); + }); + }); + + describe("registerForExam", () => { + it("should create user and timer correctly", async () => { + const now = new Date("2024-01-15T12:00:00Z"); // Monday (1) + setSystemTime(now); + + const { userService } = await import("@shared/modules/user/user.service"); + (userService.getOrCreateUser as any).mockResolvedValue({ id: 1n, xp: 500n }); + + const result = await examService.registerForExam("1", "testuser"); + + expect(result.status).toBe(ExamStatus.NOT_REGISTERED); + expect(result.examDay).toBe(1); + + expect(mockInsert).toHaveBeenCalledWith(userTimers); + expect(mockInsert).toHaveBeenCalledTimes(1); + }); + }); + + describe("takeExam", () => { + it("should return NOT_REGISTERED if not registered", async () => { + mockFindFirst.mockResolvedValueOnce({ id: 1n }) // user check + .mockResolvedValueOnce(undefined); // timer check + + const result = await examService.takeExam("1"); + expect(result.status).toBe(ExamStatus.NOT_REGISTERED); + }); + + it("should handle missed exam and schedule for next exam day", async () => { + const now = new Date("2024-01-15T12:00:00Z"); // Monday (1) + setSystemTime(now); + const past = new Date("2024-01-10T00:00:00Z"); + + mockFindFirst.mockResolvedValueOnce({ id: 1n, xp: 600n }) // user + .mockResolvedValueOnce({ + expiresAt: past, + metadata: { examDay: 3, lastXp: "500" } // Registered for Wednesday + }); // timer + + const result = await examService.takeExam("1"); + + expect(result.status).toBe(ExamStatus.MISSED); + expect(result.examDay).toBe(3); + + // Should set next exam to next Wednesday + // Monday (1) + 2 days = Wednesday (3) + const expected = new Date("2024-01-17T00:00:00Z"); + expect(result.nextExamAt!.getTime()).toBe(expected.getTime()); + + expect(mockUpdate).toHaveBeenCalledWith(userTimers); + }); + + it("should calculate rewards and update state when passed", async () => { + const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3) + setSystemTime(now); + const past = new Date("2024-01-10T00:00:00Z"); + + mockFindFirst.mockResolvedValueOnce({ id: 1n, username: "testuser", xp: 1000n, balance: 0n }) // user + .mockResolvedValueOnce({ + expiresAt: past, + metadata: { examDay: 3, lastXp: "500" } + }); // timer + + const result = await examService.takeExam("1"); + + expect(result.status).toBe(ExamStatus.AVAILABLE); + expect(result.xpDiff).toBe(500n); + // Multiplier is between 1.0 and 2.0 based on mock config + expect(result.multiplier).toBeGreaterThanOrEqual(1.0); + expect(result.multiplier).toBeLessThanOrEqual(2.0); + expect(result.reward).toBeGreaterThanOrEqual(500n); + expect(result.reward).toBeLessThanOrEqual(1000n); + + expect(mockUpdate).toHaveBeenCalledWith(userTimers); + expect(mockUpdate).toHaveBeenCalledWith(users); + + // Verify transaction + expect(mockInsert).toHaveBeenCalledWith(transactions); + expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({ + amount: result.reward, + userId: 1n, + type: expect.anything() + })); + }); + }); +}); diff --git a/shared/modules/economy/exam.service.ts b/shared/modules/economy/exam.service.ts new file mode 100644 index 0000000..5f01fe1 --- /dev/null +++ b/shared/modules/economy/exam.service.ts @@ -0,0 +1,262 @@ +import { users, userTimers, transactions } from "@db/schema"; +import { eq, and, sql } from "drizzle-orm"; +import { TimerType, TransactionType } from "@shared/lib/constants"; +import { config } from "@shared/lib/config"; +import { withTransaction } from "@/lib/db"; +import type { Transaction } from "@shared/lib/types"; +import { UserError } from "@shared/lib/errors"; + +const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM; +const EXAM_TIMER_KEY = 'default'; + +export interface ExamMetadata { + examDay: number; + lastXp: string; +} + +export enum ExamStatus { + NOT_REGISTERED = 'NOT_REGISTERED', + COOLDOWN = 'COOLDOWN', + MISSED = 'MISSED', + AVAILABLE = 'AVAILABLE', +} + +export interface ExamActionResult { + status: ExamStatus; + nextExamAt?: Date; + reward?: bigint; + xpDiff?: bigint; + multiplier?: number; + examDay?: number; +} + +export const examService = { + /** + * Get the current exam status for a user. + */ + async getExamStatus(userId: string, tx?: Transaction) { + return await withTransaction(async (txFn) => { + const timer = await txFn.query.userTimers.findFirst({ + where: and( + eq(userTimers.userId, BigInt(userId)), + eq(userTimers.type, EXAM_TIMER_TYPE), + eq(userTimers.key, EXAM_TIMER_KEY) + ) + }); + + if (!timer) { + return { status: ExamStatus.NOT_REGISTERED }; + } + + const now = new Date(); + const expiresAt = new Date(timer.expiresAt); + expiresAt.setHours(0, 0, 0, 0); + + if (now < expiresAt) { + return { + status: ExamStatus.COOLDOWN, + nextExamAt: expiresAt + }; + } + + const metadata = timer.metadata as unknown as ExamMetadata; + const currentDay = now.getDay(); + + if (currentDay !== metadata.examDay) { + return { + status: ExamStatus.MISSED, + nextExamAt: expiresAt, + examDay: metadata.examDay + }; + } + + return { + status: ExamStatus.AVAILABLE, + examDay: metadata.examDay, + lastXp: BigInt(metadata.lastXp || "0") + }; + }, tx); + }, + + /** + * Register a user for the first time. + */ + async registerForExam(userId: string, username: string, tx?: Transaction): Promise { + return await withTransaction(async (txFn) => { + // Ensure user exists + const { userService } = await import("@shared/modules/user/user.service"); + const user = await userService.getOrCreateUser(userId, username, txFn); + if (!user) throw new Error("Failed to get or create user."); + + const now = new Date(); + const currentDay = now.getDay(); + + // Set next exam to next week + const nextExamDate = new Date(now); + nextExamDate.setDate(now.getDate() + 7); + nextExamDate.setHours(0, 0, 0, 0); + + const metadata: ExamMetadata = { + examDay: currentDay, + lastXp: (user.xp ?? 0n).toString() + }; + + await txFn.insert(userTimers).values({ + userId: BigInt(userId), + type: EXAM_TIMER_TYPE, + key: EXAM_TIMER_KEY, + expiresAt: nextExamDate, + metadata: metadata + }); + + return { + status: ExamStatus.NOT_REGISTERED, + nextExamAt: nextExamDate, + examDay: currentDay + }; + }, tx); + }, + + /** + * Take the exam. Handles missed exams and reward calculations. + */ + async takeExam(userId: string, tx?: Transaction): Promise { + return await withTransaction(async (txFn) => { + const user = await txFn.query.users.findFirst({ + where: eq(users.id, BigInt(userId)) + }); + + if (!user) throw new Error("User not found"); + + const timer = await txFn.query.userTimers.findFirst({ + where: and( + eq(userTimers.userId, BigInt(userId)), + eq(userTimers.type, EXAM_TIMER_TYPE), + eq(userTimers.key, EXAM_TIMER_KEY) + ) + }); + + if (!timer) { + return { status: ExamStatus.NOT_REGISTERED }; + } + + const now = new Date(); + const expiresAt = new Date(timer.expiresAt); + expiresAt.setHours(0, 0, 0, 0); + + if (now < expiresAt) { + return { + status: ExamStatus.COOLDOWN, + nextExamAt: expiresAt + }; + } + + const metadata = timer.metadata as unknown as ExamMetadata; + const examDay = metadata.examDay; + const currentDay = now.getDay(); + + if (currentDay !== examDay) { + // Missed exam logic + let daysUntil = (examDay - currentDay + 7) % 7; + if (daysUntil === 0) daysUntil = 7; + + const nextExamDate = new Date(now); + nextExamDate.setDate(now.getDate() + daysUntil); + nextExamDate.setHours(0, 0, 0, 0); + + const newMetadata: ExamMetadata = { + examDay: examDay, + lastXp: (user.xp ?? 0n).toString() + }; + + await txFn.update(userTimers) + .set({ + expiresAt: nextExamDate, + metadata: newMetadata + }) + .where(and( + eq(userTimers.userId, BigInt(userId)), + eq(userTimers.type, EXAM_TIMER_TYPE), + eq(userTimers.key, EXAM_TIMER_KEY) + )); + + return { + status: ExamStatus.MISSED, + nextExamAt: nextExamDate, + examDay: examDay + }; + } + + // Reward Calculation + const lastXp = BigInt(metadata.lastXp || "0"); + const currentXp = user.xp ?? 0n; + const diff = currentXp - lastXp; + + const multMin = config.economy.exam.multMin; + const multMax = config.economy.exam.multMax; + const multiplier = Math.random() * (multMax - multMin) + multMin; + + let reward = 0n; + if (diff > 0n) { + // Use scaled BigInt arithmetic to avoid precision loss with large XP values + const scaledMultiplier = BigInt(Math.round(multiplier * 10000)); + reward = (diff * scaledMultiplier) / 10000n; + } + + const nextExamDate = new Date(now); + nextExamDate.setDate(now.getDate() + 7); + nextExamDate.setHours(0, 0, 0, 0); + + const newMetadata: ExamMetadata = { + examDay: examDay, + lastXp: currentXp.toString() + }; + + // Update Timer + await txFn.update(userTimers) + .set({ + expiresAt: nextExamDate, + metadata: newMetadata + }) + .where(and( + eq(userTimers.userId, BigInt(userId)), + eq(userTimers.type, EXAM_TIMER_TYPE), + eq(userTimers.key, EXAM_TIMER_KEY) + )); + + // Add Currency + if (reward > 0n) { + await txFn.update(users) + .set({ + balance: sql`${users.balance} + ${reward}` + }) + .where(eq(users.id, BigInt(userId))); + + // Add Transaction Record + await txFn.insert(transactions).values({ + userId: BigInt(userId), + amount: reward, + type: TransactionType.EXAM_REWARD, + description: `Weekly exam reward (XP Diff: ${diff})`, + }); + } + + // Record dashboard event + const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service"); + await dashboardService.recordEvent({ + type: 'success', + message: `${user.username} passed their exam: ${reward.toLocaleString()} AU`, + icon: '🎓' + }); + + return { + status: ExamStatus.AVAILABLE, + nextExamAt: nextExamDate, + reward, + xpDiff: diff, + multiplier, + examDay + }; + }, tx); + } +}; diff --git a/shared/modules/inventory/inventory.service.ts b/shared/modules/inventory/inventory.service.ts index 7f702d4..167b1e7 100644 --- a/shared/modules/inventory/inventory.service.ts +++ b/shared/modules/inventory/inventory.service.ts @@ -37,6 +37,11 @@ export const inventoryService = { eq(inventory.itemId, itemId) )) .returning(); + + // Trigger Quest Event + const { questService } = await import("@shared/modules/quest/quest.service"); + await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, Number(quantity), txFn); + return entry; } else { // Check Slot Limit @@ -60,6 +65,11 @@ export const inventoryService = { quantity: quantity, }) .returning(); + + // Trigger Quest Event + const { questService } = await import("@shared/modules/quest/quest.service"); + await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, Number(quantity), txFn); + return entry; } }, tx); @@ -179,6 +189,10 @@ export const inventoryService = { await inventoryService.removeItem(userId, itemId, 1n, txFn); } + // Trigger Quest Event + const { questService } = await import("@shared/modules/quest/quest.service"); + await questService.handleEvent(userId, `ITEM_USE:${itemId}`, 1, txFn); + return { success: true, results, usageData, item }; }, tx); }, diff --git a/shared/modules/leveling/leveling.service.ts b/shared/modules/leveling/leveling.service.ts index f6af7e5..a009c51 100644 --- a/shared/modules/leveling/leveling.service.ts +++ b/shared/modules/leveling/leveling.service.ts @@ -68,6 +68,10 @@ export const levelingService = { .where(eq(users.id, BigInt(id))) .returning(); + // Trigger Quest Event + const { questService } = await import("@shared/modules/quest/quest.service"); + await questService.handleEvent(id, 'XP_GAIN', Number(amount), txFn); + return { user: updatedUser, levelUp, currentLevel: newLevel }; }, tx); }, diff --git a/shared/modules/quest/quest.service.test.ts b/shared/modules/quest/quest.service.test.ts index 1cd1b73..96cadc6 100644 --- a/shared/modules/quest/quest.service.test.ts +++ b/shared/modules/quest/quest.service.test.ts @@ -33,6 +33,7 @@ mock.module("@shared/db/DrizzleClient", () => { const createMockTx = () => ({ query: { userQuests: { findFirst: mockFindFirst, findMany: mockFindMany }, + quests: { findMany: mockFindMany }, }, insert: mockInsert, update: mockUpdate, @@ -148,4 +149,147 @@ describe("questService", () => { expect(result).toEqual(mockData as any); }); }); + + describe("getAvailableQuests", () => { + it("should return quests not yet accepted by user", async () => { + // First call to findMany (userQuests) returns accepted quest IDs + // Second call to findMany (quests) returns available quests + mockFindMany + .mockResolvedValueOnce([{ questId: 1 }]) // userQuests + .mockResolvedValueOnce([{ id: 2, name: "New Quest" }]); // quests + + const result = await questService.getAvailableQuests("1"); + + expect(result).toEqual([{ id: 2, name: "New Quest" }] as any); + expect(mockFindMany).toHaveBeenCalledTimes(2); + }); + + it("should return all quests if user has no assigned quests", async () => { + mockFindMany + .mockResolvedValueOnce([]) // userQuests + .mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); // quests + + const result = await questService.getAvailableQuests("1"); + + expect(result).toEqual([{ id: 1 }, { id: 2 }] as any); + }); + }); + + describe("handleEvent", () => { + it("should progress a quest with sub-events", async () => { + const mockUserQuest = { + userId: 1n, + questId: 101, + progress: 0, + completedAt: null, + quest: { triggerEvent: "ITEM_USE:101", requirements: { target: 5 } } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + mockReturning.mockResolvedValue([{ userId: 1n, questId: 101, progress: 1 }]); + + await questService.handleEvent("1", "ITEM_USE:101", 1); + + expect(mockUpdate).toHaveBeenCalled(); + expect(mockSet).toHaveBeenCalledWith({ progress: 1 }); + }); + + it("should complete a quest when target reached using sub-events", async () => { + const mockUserQuest = { + userId: 1n, + questId: 101, + progress: 4, + completedAt: null, + quest: { + triggerEvent: "ITEM_COLLECT:505", + requirements: { target: 5 }, + rewards: { balance: 100 } + } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + mockFindFirst.mockResolvedValue(mockUserQuest); // For completeQuest + + await questService.handleEvent("1", "ITEM_COLLECT:505", 1); + + // Verify completeQuest was called (it will update completedAt) + expect(mockUpdate).toHaveBeenCalled(); + expect(mockSet).toHaveBeenCalledWith({ completedAt: expect.any(Date) }); + }); + + it("should progress a quest with generic events", async () => { + const mockUserQuest = { + userId: 1n, + questId: 102, + progress: 0, + completedAt: null, + quest: { triggerEvent: "ITEM_COLLECT", requirements: { target: 5 } } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + mockReturning.mockResolvedValue([{ userId: 1n, questId: 102, progress: 1 }]); + + await questService.handleEvent("1", "ITEM_COLLECT:505", 1); + + expect(mockUpdate).toHaveBeenCalled(); + expect(mockSet).toHaveBeenCalledWith({ progress: 1 }); + }); + + it("should ignore events that are not prefix matches", async () => { + const mockUserQuest = { + userId: 1n, + questId: 103, + progress: 0, + completedAt: null, + quest: { triggerEvent: "ITEM_COLLECT", requirements: { target: 5 } } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + + await questService.handleEvent("1", "ITEM_COLLECT_UNRELATED", 1); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("should not progress a specific quest with a different specific event", async () => { + const mockUserQuest = { + userId: 1n, + questId: 104, + progress: 0, + completedAt: null, + quest: { triggerEvent: "ITEM_COLLECT:101", requirements: { target: 5 } } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + + await questService.handleEvent("1", "ITEM_COLLECT:202", 1); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("should not progress a specific quest with a generic event", async () => { + const mockUserQuest = { + userId: 1n, + questId: 105, + progress: 0, + completedAt: null, + quest: { triggerEvent: "ITEM_COLLECT:101", requirements: { target: 5 } } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + + await questService.handleEvent("1", "ITEM_COLLECT", 1); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("should ignore irrelevant events", async () => { + const mockUserQuest = { + userId: 1n, + questId: 101, + progress: 0, + completedAt: null, + quest: { triggerEvent: "DIFFERENT_EVENT", requirements: { target: 5 } } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + + await questService.handleEvent("1", "TEST_EVENT", 1); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + }); }); diff --git a/shared/modules/quest/quest.service.ts b/shared/modules/quest/quest.service.ts index db1199b..c9efe65 100644 --- a/shared/modules/quest/quest.service.ts +++ b/shared/modules/quest/quest.service.ts @@ -1,4 +1,4 @@ -import { userQuests } from "@db/schema"; +import { userQuests, quests } from "@db/schema"; import { eq, and } from "drizzle-orm"; import { UserError } from "@shared/lib/errors"; import { DrizzleClient } from "@shared/db/DrizzleClient"; @@ -7,6 +7,7 @@ import { levelingService } from "@shared/modules/leveling/leveling.service"; import { withTransaction } from "@/lib/db"; import type { Transaction } from "@shared/lib/types"; import { TransactionType } from "@shared/lib/constants"; +import { systemEvents, EVENTS } from "@shared/lib/events"; export const questService = { assignQuest: async (userId: string, questId: number, tx?: Transaction) => { @@ -34,6 +35,40 @@ export const questService = { }, tx); }, + handleEvent: async (userId: string, eventName: string, weight: number = 1, tx?: Transaction) => { + return await withTransaction(async (txFn) => { + // 1. Fetch active user quests for this event + const activeUserQuests = await txFn.query.userQuests.findMany({ + where: and( + eq(userQuests.userId, BigInt(userId)), + ), + with: { + quest: true + } + }); + + const relevant = activeUserQuests.filter(uq => { + const trigger = uq.quest.triggerEvent; + // Exact match or prefix match (e.g. ITEM_COLLECT matches ITEM_COLLECT:101) + const isMatch = eventName === trigger || eventName.startsWith(trigger + ":"); + return isMatch && !uq.completedAt; + }); + + for (const uq of relevant) { + const requirements = uq.quest.requirements as { target?: number }; + const target = requirements?.target || 1; + + const newProgress = (uq.progress || 0) + weight; + + if (newProgress >= target) { + await questService.completeQuest(userId, uq.questId, txFn); + } else { + await questService.updateProgress(userId, uq.questId, newProgress, txFn); + } + } + }, tx); + }, + completeQuest: async (userId: string, questId: number, tx?: Transaction) => { return await withTransaction(async (txFn) => { const userQuest = await txFn.query.userQuests.findFirst({ @@ -73,6 +108,14 @@ export const questService = { results.xp = xp; } + // Emit completion event for the bot to handle notifications + systemEvents.emit(EVENTS.QUEST.COMPLETED, { + userId, + questId, + quest: userQuest.quest, + rewards: results + }); + return { success: true, rewards: results }; }, tx); }, @@ -84,5 +127,75 @@ export const questService = { quest: true, } }); + }, + + async getAvailableQuests(userId: string) { + const userQuestIds = (await DrizzleClient.query.userQuests.findMany({ + where: eq(userQuests.userId, BigInt(userId)), + columns: { + questId: true + } + })).map(uq => uq.questId); + + return await DrizzleClient.query.quests.findMany({ + where: (quests, { notInArray }) => userQuestIds.length > 0 + ? notInArray(quests.id, userQuestIds) + : undefined + }); + }, + + async createQuest(data: { + name: string; + description: string; + triggerEvent: string; + requirements: { target: number }; + rewards: { xp: number; balance: number }; + }, tx?: Transaction) { + return await withTransaction(async (txFn) => { + return await txFn.insert(quests) + .values({ + name: data.name, + description: data.description, + triggerEvent: data.triggerEvent, + requirements: data.requirements, + rewards: data.rewards, + }) + .returning(); + }, tx); + }, + + async getAllQuests() { + return await DrizzleClient.query.quests.findMany({ + orderBy: (quests, { asc }) => [asc(quests.id)], + }); + }, + + async deleteQuest(id: number, tx?: Transaction) { + return await withTransaction(async (txFn) => { + return await txFn.delete(quests) + .where(eq(quests.id, id)) + .returning(); + }, tx); + }, + + async updateQuest(id: number, data: { + name?: string; + description?: string; + triggerEvent?: string; + requirements?: { target?: number }; + rewards?: { xp?: number; balance?: number }; + }, tx?: Transaction) { + return await withTransaction(async (txFn) => { + return await txFn.update(quests) + .set({ + ...(data.name !== undefined && { name: data.name }), + ...(data.description !== undefined && { description: data.description }), + ...(data.triggerEvent !== undefined && { triggerEvent: data.triggerEvent }), + ...(data.requirements !== undefined && { requirements: data.requirements }), + ...(data.rewards !== undefined && { rewards: data.rewards }), + }) + .where(eq(quests.id, id)) + .returning(); + }, tx); } }; diff --git a/shared/scripts/docker-cleanup.sh b/shared/scripts/docker-cleanup.sh new file mode 100755 index 0000000..ebd391a --- /dev/null +++ b/shared/scripts/docker-cleanup.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Cleanup script for Docker resources +# Use: ./shared/scripts/docker-cleanup.sh +# Use: ./shared/scripts/docker-cleanup.sh --full (for aggressive cleanup) + +set -e + +echo "🧹 Aurora Docker Cleanup" +echo "========================" +echo "" + +# Show current disk usage first +echo "📊 Current Docker disk usage:" +docker system df +echo "" + +# Stop running containers for this project +echo "📦 Stopping Aurora containers..." +docker compose down 2>/dev/null || true + +# Remove dangling images (untagged images from failed builds) +echo "" +echo "🗑️ Removing dangling images..." +docker image prune -f + +# Check for --full flag for aggressive cleanup +if [[ "$1" == "--full" ]]; then + echo "" + echo "🔥 Full cleanup mode - removing all unused Docker resources..." + + # Remove all unused images, not just dangling ones + echo " → Removing unused images..." + docker image prune -a -f + + # Remove build cache + echo " → Removing build cache..." + docker builder prune -a -f + + # Remove unused volumes (except named ones we need) + echo " → Removing unused volumes..." + docker volume prune -f + + # Remove unused networks + echo " → Removing unused networks..." + docker network prune -f + + # Remove node_modules volumes + echo " → Removing node_modules volumes..." + docker volume rm aurora_app_node_modules aurora_web_node_modules 2>/dev/null || true + + echo "" + echo "✅ Full cleanup complete!" +else + # Interactive mode + echo "" + read -p "🔧 Remove Docker build cache? (y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + docker builder prune -f + echo "✓ Build cache cleared" + fi + + echo "" + read -p "🖼️ Remove ALL unused images (not just dangling)? (y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + docker image prune -a -f + echo "✓ Unused images removed" + fi + + echo "" + read -p "📁 Remove node_modules volumes? (forces fresh install) (y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + docker volume rm aurora_app_node_modules aurora_web_node_modules 2>/dev/null || true + echo "✓ Node modules volumes removed" + fi + + echo "" + read -p "🧨 Run full system prune (removes ALL unused data)? (y/N): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + docker system prune -a -f --volumes + echo "✓ Full system prune complete" + fi + + echo "" + echo "✅ Cleanup complete!" +fi + +echo "" +echo "📊 Docker disk usage after cleanup:" +docker system df +echo "" +echo "💡 Tip: Check container logs with: sudo du -sh /var/lib/docker/containers/*/*.log" +echo "💡 Tip: Truncate logs with: sudo truncate -s 0 /var/lib/docker/containers/*/*.log" +echo "" +echo "Run 'docker compose up --build' to rebuild" diff --git a/tsconfig.json b/tsconfig.json index 6e79a5d..e4570b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, + "resolveJsonModule": true, "noEmit": true, // Best practices "strict": true, diff --git a/web/build.ts b/web/build.ts index 5c7850f..76aaf15 100644 --- a/web/build.ts +++ b/web/build.ts @@ -135,6 +135,7 @@ const build = async () => { minify: true, target: "browser", sourcemap: "linked", + publicPath: "/", // Use absolute paths for SPA routing compatibility define: { "process.env.NODE_ENV": JSON.stringify((cliConfig as any).watch ? "development" : "production"), }, @@ -159,14 +160,86 @@ console.log(`\n✅ Build completed in ${buildTime}ms\n`); if ((cliConfig as any).watch) { console.log("👀 Watching for changes...\n"); - // Keep the process alive for watch mode - // Bun.build with watch:true handles the watching, - // we just need to make sure the script doesn't exit. - process.stdin.resume(); - // Also, handle manual exit + // Polling-based file watcher for Docker compatibility + // Docker volumes don't propagate filesystem events (inotify) reliably + const srcDir = path.join(process.cwd(), "src"); + const POLL_INTERVAL_MS = 1000; + let lastMtimes = new Map(); + let isRebuilding = false; + + // Collect all file mtimes in src directory + const collectMtimes = async (): Promise> => { + const mtimes = new Map(); + const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx,css,html}"); + + for await (const file of glob.scan({ cwd: srcDir, absolute: true })) { + try { + const stat = await Bun.file(file).stat(); + if (stat) { + mtimes.set(file, stat.mtime.getTime()); + } + } catch { + // File may have been deleted, skip + } + } + return mtimes; + }; + + // Initial collection + lastMtimes = await collectMtimes(); + + // Polling loop + const poll = async () => { + if (isRebuilding) return; + + const currentMtimes = await collectMtimes(); + const changedFiles: string[] = []; + + // Check for new or modified files + for (const [file, mtime] of currentMtimes) { + const lastMtime = lastMtimes.get(file); + if (lastMtime === undefined || lastMtime < mtime) { + changedFiles.push(path.relative(srcDir, file)); + } + } + + // Check for deleted files + for (const file of lastMtimes.keys()) { + if (!currentMtimes.has(file)) { + changedFiles.push(path.relative(srcDir, file) + " (deleted)"); + } + } + + if (changedFiles.length > 0) { + isRebuilding = true; + console.log(`\n🔄 Changes detected:`); + changedFiles.forEach(f => console.log(` • ${f}`)); + console.log(""); + + try { + const rebuildStart = performance.now(); + await build(); + const rebuildEnd = performance.now(); + console.log(`\n✅ Rebuild completed in ${(rebuildEnd - rebuildStart).toFixed(2)}ms\n`); + } catch (err) { + console.error("❌ Rebuild failed:", err); + } + + lastMtimes = currentMtimes; + isRebuilding = false; + } + }; + + const interval = setInterval(poll, POLL_INTERVAL_MS); + + // Handle manual exit process.on("SIGINT", () => { + clearInterval(interval); console.log("\n👋 Stopping build watcher..."); process.exit(0); }); + + // Keep process alive + process.stdin.resume(); } diff --git a/web/bun.lock b/web/bun.lock index 773abef..4845368 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -7,7 +7,9 @@ "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", @@ -20,6 +22,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.562.0", + "next-themes": "^0.4.6", "react": "^19", "react-dom": "^19", "react-hook-form": "^7.70.0", @@ -93,6 +96,8 @@ "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], @@ -101,6 +106,8 @@ "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], @@ -233,6 +240,8 @@ "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], @@ -295,6 +304,8 @@ "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/web/package.json b/web/package.json index 5a9e149..5d0e10e 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,9 @@ "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", @@ -24,6 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.562.0", + "next-themes": "^0.4.6", "react": "^19", "react-dom": "^19", "react-hook-form": "^7.70.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index f6362d3..cd98cc2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,21 +1,52 @@ -import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import "./index.css"; -import { Dashboard } from "./pages/Dashboard"; + import { DesignSystem } from "./pages/DesignSystem"; +import { AdminQuests } from "./pages/AdminQuests"; +import { AdminOverview } from "./pages/admin/Overview"; +import { AdminItems } from "./pages/admin/Items"; + import { Home } from "./pages/Home"; import { Toaster } from "sonner"; +import { NavigationProvider } from "./contexts/navigation-context"; +import { MainLayout } from "./components/layout/main-layout"; + +import { SettingsLayout } from "./pages/settings/SettingsLayout"; +import { GeneralSettings } from "./pages/settings/General"; +import { EconomySettings } from "./pages/settings/Economy"; +import { SystemsSettings } from "./pages/settings/Systems"; +import { RolesSettings } from "./pages/settings/Roles"; export function App() { return ( - - - } /> - } /> - } /> - + + + + + + } /> + } /> + } /> + } /> + } /> + + + }> + } /> + } /> + } /> + } /> + } /> + + + } /> + + + ); } export default App; + diff --git a/web/src/components/commands-drawer.tsx b/web/src/components/commands-drawer.tsx index ff4cc2e..9b4f3f0 100644 --- a/web/src/components/commands-drawer.tsx +++ b/web/src/components/commands-drawer.tsx @@ -51,7 +51,9 @@ export function CommandsDrawer({ open, onOpenChange }: CommandsDrawerProps) { } setEnabledState(state); }).catch(err => { - toast.error("Failed to load commands"); + toast.error("Failed to load commands", { + description: "Unable to fetch command list. Please try again." + }); console.error(err); }).finally(() => { setLoading(false); @@ -94,11 +96,14 @@ export function CommandsDrawer({ open, onOpenChange }: CommandsDrawerProps) { setEnabledState(prev => ({ ...prev, [commandName]: enabled })); toast.success(`/${commandName} ${enabled ? "enabled" : "disabled"}`, { + description: `Command has been ${enabled ? "enabled" : "disabled"} successfully.`, duration: 2000, - id: "command-toggle", // Replace previous toast instead of stacking + id: "command-toggle", }); } catch (error) { - toast.error("Failed to toggle command"); + toast.error("Failed to toggle command", { + description: "Unable to update command status. Please try again." + }); console.error(error); } finally { setSaving(null); diff --git a/web/src/components/layout/app-sidebar.tsx b/web/src/components/layout/app-sidebar.tsx new file mode 100644 index 0000000..613e935 --- /dev/null +++ b/web/src/components/layout/app-sidebar.tsx @@ -0,0 +1,220 @@ +import { Link } from "react-router-dom" +import { ChevronRight } from "lucide-react" +import { + Sidebar, + SidebarContent, + + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubItem, + SidebarMenuSubButton, + SidebarGroup, + SidebarGroupLabel, + SidebarGroupContent, + SidebarRail, + useSidebar, +} from "@/components/ui/sidebar" +import { useNavigation, type NavItem } from "@/contexts/navigation-context" + +import { cn } from "@/lib/utils" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useSocket } from "@/hooks/use-socket" + +function NavItemWithSubMenu({ item }: { item: NavItem }) { + const { state } = useSidebar() + const isCollapsed = state === "collapsed" + + // When collapsed, show a dropdown menu on hover/click + if (isCollapsed) { + return ( + + + + + + + + +
+ {item.title} +
+ {item.subItems?.map((subItem) => ( + + + + {subItem.title} + + + ))} +
+
+
+ ) + } + + // When expanded, show collapsible sub-menu + return ( + + + + + + + {item.title} + + + + + + + {item.subItems?.map((subItem) => ( + + + + + {subItem.title} + + + + ))} + + + + + ) +} + +function NavItemLink({ item }: { item: NavItem }) { + return ( + + + + + {item.title} + + + + ) +} + +export function AppSidebar() { + const { navItems } = useNavigation() + const { stats } = useSocket() + + return ( + + + + + + + {stats?.bot?.avatarUrl ? ( + {stats.bot.name} + ) : ( +
+ Aurora +
+ )} +
+ Aurora + + {stats?.bot?.status || "Online"} + +
+ +
+
+
+
+ + + + + Menu + + + + {navItems.map((item) => ( + item.subItems ? ( + + ) : ( + + ) + ))} + + + + + + + + +
+ ) +} + diff --git a/web/src/components/layout/main-layout.tsx b/web/src/components/layout/main-layout.tsx new file mode 100644 index 0000000..9383977 --- /dev/null +++ b/web/src/components/layout/main-layout.tsx @@ -0,0 +1,56 @@ +import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar" +import { AppSidebar } from "./app-sidebar" +import { MobileNav } from "@/components/navigation/mobile-nav" +import { useIsMobile } from "@/hooks/use-mobile" +import { Separator } from "@/components/ui/separator" +import { useNavigation } from "@/contexts/navigation-context" + +interface MainLayoutProps { + children: React.ReactNode +} + +export function MainLayout({ children }: MainLayoutProps) { + const isMobile = useIsMobile() + const { breadcrumbs, currentTitle } = useNavigation() + + return ( + + + + {/* Header with breadcrumbs */} +
+
+ + + +
+
+ + {/* Main content */} +
+ {children} +
+ + {/* Mobile bottom navigation */} + {isMobile && } +
+
+ ) +} diff --git a/web/src/components/navigation/mobile-nav.tsx b/web/src/components/navigation/mobile-nav.tsx new file mode 100644 index 0000000..c9f8d47 --- /dev/null +++ b/web/src/components/navigation/mobile-nav.tsx @@ -0,0 +1,41 @@ +import { Link } from "react-router-dom" +import { useNavigation } from "@/contexts/navigation-context" +import { cn } from "@/lib/utils" + +export function MobileNav() { + const { navItems } = useNavigation() + + return ( + + ) +} diff --git a/web/src/components/quest-form.tsx b/web/src/components/quest-form.tsx new file mode 100644 index 0000000..3ea362b --- /dev/null +++ b/web/src/components/quest-form.tsx @@ -0,0 +1,297 @@ +import React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./ui/card"; +import { Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from "./ui/form"; +import { Input } from "./ui/input"; +import { Button } from "./ui/button"; +import { Textarea } from "./ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { toast } from "sonner"; +import { ScrollArea } from "./ui/scroll-area"; +import { Star, Coins } from "lucide-react"; + +interface QuestListItem { + id: number; + name: string; + description: string | null; + triggerEvent: string; + requirements: { target?: number }; + rewards: { xp?: number; balance?: number }; +} + +const questSchema = z.object({ + name: z.string().min(3, "Name must be at least 3 characters"), + description: z.string().optional(), + triggerEvent: z.string().min(1, "Trigger event is required"), + target: z.number().min(1, "Target must be at least 1"), + xpReward: z.number().min(0).optional(), + balanceReward: z.number().min(0).optional(), +}); + +type QuestFormValues = z.infer; + +interface QuestFormProps { + initialData?: QuestListItem; + onUpdate?: () => void; + onCancel?: () => void; +} + +const TRIGGER_EVENTS = [ + { label: "XP Gain", value: "XP_GAIN" }, + { label: "Item Collect", value: "ITEM_COLLECT" }, + { label: "Item Use", value: "ITEM_USE" }, + { label: "Daily Reward", value: "DAILY_REWARD" }, + { label: "Lootbox Currency Reward", value: "LOOTBOX" }, + { label: "Exam Reward", value: "EXAM_REWARD" }, + { label: "Purchase", value: "PURCHASE" }, + { label: "Transfer In", value: "TRANSFER_IN" }, + { label: "Transfer Out", value: "TRANSFER_OUT" }, + { label: "Trade In", value: "TRADE_IN" }, + { label: "Trade Out", value: "TRADE_OUT" }, + { label: "Quest Reward", value: "QUEST_REWARD" }, + { label: "Trivia Entry", value: "TRIVIA_ENTRY" }, + { label: "Trivia Win", value: "TRIVIA_WIN" }, +]; + +export function QuestForm({ initialData, onUpdate, onCancel }: QuestFormProps) { + const isEditMode = initialData !== undefined; + const [isSubmitting, setIsSubmitting] = React.useState(false); + const form = useForm({ + resolver: zodResolver(questSchema), + defaultValues: { + name: initialData?.name || "", + description: initialData?.description || "", + triggerEvent: initialData?.triggerEvent || "XP_GAIN", + target: (initialData?.requirements as { target?: number })?.target || 1, + xpReward: (initialData?.rewards as { xp?: number })?.xp || 100, + balanceReward: (initialData?.rewards as { balance?: number })?.balance || 500, + }, + }); + + React.useEffect(() => { + if (initialData) { + form.reset({ + name: initialData.name || "", + description: initialData.description || "", + triggerEvent: initialData.triggerEvent || "XP_GAIN", + target: (initialData.requirements as { target?: number })?.target || 1, + xpReward: (initialData.rewards as { xp?: number })?.xp || 100, + balanceReward: (initialData.rewards as { balance?: number })?.balance || 500, + }); + } + }, [initialData, form]); + + const onSubmit = async (data: QuestFormValues) => { + setIsSubmitting(true); + try { + const url = isEditMode ? `/api/quests/${initialData.id}` : "/api/quests"; + const method = isEditMode ? "PUT" : "POST"; + + const response = await fetch(url, { + method: method, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || (isEditMode ? "Failed to update quest" : "Failed to create quest")); + } + + toast.success(isEditMode ? "Quest updated successfully!" : "Quest created successfully!", { + description: `${data.name} has been ${isEditMode ? "updated" : "added to the database"}.`, + }); + form.reset({ + name: "", + description: "", + triggerEvent: "XP_GAIN", + target: 1, + xpReward: 100, + balanceReward: 500, + }); + onUpdate?.(); + } catch (error) { + console.error("Submission error:", error); + toast.error(isEditMode ? "Failed to update quest" : "Failed to create quest", { + description: error instanceof Error ? error.message : "An unknown error occurred", + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+ + {isEditMode ? "Edit Quest" : "Create New Quest"} + + {isEditMode ? "Update the quest configuration." : "Configure a new quest for the Aurora RPG academy."} + + + +
+ +
+ ( + + Quest Name + + + + + + )} + /> + + ( + + Trigger Event + + + + )} + /> +
+ + ( + + Description + +