diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ca802c9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,238 @@ +# 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 +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/shared/modules/quest/quest.service.ts b/shared/modules/quest/quest.service.ts index bcc8048..c9efe65 100644 --- a/shared/modules/quest/quest.service.ts +++ b/shared/modules/quest/quest.service.ts @@ -168,5 +168,34 @@ export const questService = { 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/web/bun.lock b/web/bun.lock index a77d98b..4845368 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -22,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", @@ -239,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=="], diff --git a/web/package.json b/web/package.json index d90c6bb..5d0e10e 100644 --- a/web/package.json +++ b/web/package.json @@ -26,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/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 index c5ceecf..59a9d14 100644 --- a/web/src/components/layout/app-sidebar.tsx +++ b/web/src/components/layout/app-sidebar.tsx @@ -64,12 +64,12 @@ function NavItemWithSubMenu({ item }: { item: NavItem }) {
{item.title}
- {item.subItems?.map((subItem) => ( + {item.subItems?.map((subItem) => ( diff --git a/web/src/components/quest-form.tsx b/web/src/components/quest-form.tsx index 9799a59..3ea362b 100644 --- a/web/src/components/quest-form.tsx +++ b/web/src/components/quest-form.tsx @@ -10,6 +10,16 @@ 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"), @@ -22,6 +32,12 @@ const questSchema = z.object({ 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" }, @@ -39,25 +55,42 @@ const TRIGGER_EVENTS = [ { label: "Trivia Win", value: "TRIVIA_WIN" }, ]; -export function QuestForm({ onSuccess }: { onSuccess?: () => void }) { +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: "", - description: "", - triggerEvent: "XP_GAIN", - target: 1, - xpReward: 100, - balanceReward: 500, + 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 response = await fetch("/api/quests", { - method: "POST", + 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", }, @@ -66,17 +99,24 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) { if (!response.ok) { const errorData = await response.json(); - throw new Error(errorData.error || "Failed to create quest"); + throw new Error(errorData.error || (isEditMode ? "Failed to update quest" : "Failed to create quest")); } - toast.success("Quest created successfully!", { - description: `${data.name} has been added to the database.`, + toast.success(isEditMode ? "Quest updated successfully!" : "Quest created successfully!", { + description: `${data.name} has been ${isEditMode ? "updated" : "added to the database"}.`, }); - form.reset(); - onSuccess?.(); + form.reset({ + name: "", + description: "", + triggerEvent: "XP_GAIN", + target: 1, + xpReward: 100, + balanceReward: 500, + }); + onUpdate?.(); } catch (error) { console.error("Submission error:", error); - toast.error("Failed to create quest", { + toast.error(isEditMode ? "Failed to update quest" : "Failed to create quest", { description: error instanceof Error ? error.message : "An unknown error occurred", }); } finally { @@ -85,12 +125,12 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) { }; return ( - +
- Create New Quest + {isEditMode ? "Edit Quest" : "Create New Quest"} - Configure a new quest for the Aurora RPG academy. + {isEditMode ? "Update the quest configuration." : "Configure a new quest for the Aurora RPG academy."} @@ -182,7 +222,10 @@ export function QuestForm({ onSuccess }: { onSuccess?: () => void }) { name="xpReward" render={({ field }) => ( - XP Reward + + + XP Reward + void }) { name="balanceReward" render={({ field }) => ( - AU Reward + + + AU Reward + void }) { />
- + {isEditMode ? ( +
+ + +
+ ) : ( + + )} diff --git a/web/src/components/quest-table.tsx b/web/src/components/quest-table.tsx index b592147..2cb9ac6 100644 --- a/web/src/components/quest-table.tsx +++ b/web/src/components/quest-table.tsx @@ -1,10 +1,11 @@ import React from "react"; +import { toast } from "sonner"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; import { Badge } from "./ui/badge"; import { Skeleton } from "./ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { cn } from "../lib/utils"; -import { FileText, RefreshCw } from "lucide-react"; +import { FileText, RefreshCw, Trash2, Pencil, Star, Coins } from "lucide-react"; interface QuestListItem { id: number; @@ -20,6 +21,8 @@ interface QuestTableProps { isInitialLoading: boolean; isRefreshing: boolean; onRefresh?: () => void; + onDelete?: (id: number) => void; + onEdit?: (id: number) => void; } const TRIGGER_EVENT_LABELS: Record = { @@ -67,7 +70,7 @@ function TruncatedText({ text, maxLength = 100 }: { text: string; maxLength?: nu function QuestTableSkeleton() { return (
-
+
@@ -77,7 +80,7 @@ function QuestTableSkeleton() {
{Array.from({ length: 5 }).map((_, i) => ( -
+
@@ -85,6 +88,7 @@ function QuestTableSkeleton() { +
))}
@@ -105,7 +109,7 @@ function EmptyQuestState() { ); } -function QuestTableContent({ quests }: { quests: QuestListItem[] }) { +function QuestTableContent({ quests, onDelete, onEdit }: { quests: QuestListItem[]; onDelete?: (id: number) => void; onEdit?: (id: number) => void }) { if (quests.length === 0) { return ; } @@ -136,6 +140,9 @@ function QuestTableContent({ quests }: { quests: QuestListItem[] }) { AU Reward + + Actions + @@ -170,7 +177,7 @@ function QuestTableContent({ quests }: { quests: QuestListItem[] }) { {rewards?.xp ? ( - + {rewards.xp} ) : ( @@ -180,13 +187,51 @@ function QuestTableContent({ quests }: { quests: QuestListItem[] }) { {rewards?.balance ? ( - 🪙 + {rewards.balance} ) : ( - )} + +
+ + +
+ ); })} @@ -196,7 +241,7 @@ function QuestTableContent({ quests }: { quests: QuestListItem[] }) { ); } -export function QuestTable({ quests, isInitialLoading, isRefreshing, onRefresh }: QuestTableProps) { +export function QuestTable({ quests, isInitialLoading, isRefreshing, onRefresh, onDelete, onEdit }: QuestTableProps) { const showSkeleton = isInitialLoading && quests.length === 0; return ( @@ -235,7 +280,7 @@ export function QuestTable({ quests, isInitialLoading, isRefreshing, onRefresh } {showSkeleton ? ( ) : ( - + )} diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx new file mode 100644 index 0000000..9f46e06 --- /dev/null +++ b/web/src/components/ui/sonner.tsx @@ -0,0 +1,38 @@ +import { + CircleCheckIcon, + InfoIcon, + Loader2Icon, + OctagonXIcon, + TriangleAlertIcon, +} from "lucide-react" +import { useTheme } from "next-themes" +import { Toaster as Sonner, type ToasterProps } from "sonner" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + {...props} + /> + ) +} + +export { Toaster } diff --git a/web/src/hooks/use-settings.ts b/web/src/hooks/use-settings.ts index 48f19f3..4c72417 100644 --- a/web/src/hooks/use-settings.ts +++ b/web/src/hooks/use-settings.ts @@ -137,7 +137,9 @@ export function useSettings() { form.reset(config as any); setMeta(metaData); } catch (err) { - toast.error("Failed to load settings"); + toast.error("Failed to load settings", { + description: "Unable to fetch bot configuration. Please try again." + }); console.error(err); } finally { setLoading(false); @@ -165,7 +167,9 @@ export function useSettings() { // Reload settings to ensure we have the latest state await loadSettings(); } catch (error) { - toast.error("Failed to save settings"); + toast.error("Failed to save settings", { + description: error instanceof Error ? error.message : "Unable to save changes. Please try again." + }); console.error(error); } finally { setIsSaving(false); diff --git a/web/src/pages/AdminQuests.tsx b/web/src/pages/AdminQuests.tsx index 880bbd8..3b2d414 100644 --- a/web/src/pages/AdminQuests.tsx +++ b/web/src/pages/AdminQuests.tsx @@ -18,6 +18,9 @@ export function AdminQuests() { const [isInitialLoading, setIsInitialLoading] = React.useState(true); const [isRefreshing, setIsRefreshing] = React.useState(false); const [lastCreatedQuestId, setLastCreatedQuestId] = React.useState(null); + const [editingQuest, setEditingQuest] = React.useState(null); + const [isFormModeEdit, setIsFormModeEdit] = React.useState(false); + const formRef = React.useRef(null); const fetchQuests = React.useCallback(async (isRefresh = false) => { if (isRefresh) { @@ -70,6 +73,52 @@ export function AdminQuests() { }); }; + const handleDeleteQuest = async (id: number) => { + try { + const response = await fetch(`/api/quests/${id}`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || "Failed to delete quest"); + } + + setQuests((prev) => prev.filter((q) => q.id !== id)); + toast.success("Quest deleted", { + description: `Quest #${id} has been successfully deleted.`, + }); + } catch (error) { + console.error("Error deleting quest:", error); + toast.error("Failed to delete quest", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + const handleEditQuest = (id: number) => { + const quest = quests.find(q => q.id === id); + if (quest) { + setEditingQuest(quest); + setIsFormModeEdit(true); + formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }; + + const handleQuestUpdated = () => { + fetchQuests(true); + setEditingQuest(null); + setIsFormModeEdit(false); + toast.success("Quest list updated", { + description: "The quest inventory has been refreshed.", + }); + }; + + const handleFormCancel = () => { + setEditingQuest(null); + setIsFormModeEdit(false); + }; + return (
fetchQuests(true)} + onDelete={handleDeleteQuest} + onEdit={handleEditQuest} />
-
- +
+
); diff --git a/web/src/server.ts b/web/src/server.ts index 9da3982..7b1c33d 100644 --- a/web/src/server.ts +++ b/web/src/server.ts @@ -222,6 +222,67 @@ export async function createWebServer(config: WebServerConfig = {}): Promise