diff --git a/shared/modules/quest/quest.types.ts b/shared/modules/quest/quest.types.ts new file mode 100644 index 0000000..0ca30f5 --- /dev/null +++ b/shared/modules/quest/quest.types.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +export const CreateQuestSchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), + triggerEvent: z.string().min(1), + target: z.coerce.number().min(1), + xpReward: z.coerce.number().min(0), + balanceReward: z.coerce.number().min(0), +}); + +export type CreateQuestInput = z.infer; + +export const UpdateQuestSchema = z.object({ + name: z.string().min(1).optional(), + description: z.string().optional(), + triggerEvent: z.string().min(1).optional(), + target: z.coerce.number().min(1).optional(), + xpReward: z.coerce.number().min(0).optional(), + balanceReward: z.coerce.number().min(0).optional(), +}); + +export type UpdateQuestInput = z.infer; diff --git a/web/src/routes/actions.routes.ts b/web/src/routes/actions.routes.ts new file mode 100644 index 0000000..fd4b21c --- /dev/null +++ b/web/src/routes/actions.routes.ts @@ -0,0 +1,106 @@ +/** + * @fileoverview Administrative action endpoints for Aurora API. + * Provides endpoints for system administration tasks like cache clearing + * and maintenance mode toggling. + */ + +import type { RouteContext, RouteModule } from "./types"; +import { jsonResponse, errorResponse, parseBody, withErrorHandling } from "./utils"; +import { MaintenanceModeSchema } from "./schemas"; + +/** + * Admin actions routes handler. + * + * Endpoints: + * - POST /api/actions/reload-commands - Reload bot slash commands + * - POST /api/actions/clear-cache - Clear internal caches + * - POST /api/actions/maintenance-mode - Toggle maintenance mode + */ +async function handler(ctx: RouteContext): Promise { + const { pathname, method, req } = ctx; + + // Only handle POST requests to /api/actions/* + if (!pathname.startsWith("/api/actions/") || method !== "POST") { + return null; + } + + const { actionService } = await import("@shared/modules/admin/action.service"); + + /** + * @route POST /api/actions/reload-commands + * @description Triggers a reload of all Discord slash commands. + * Useful after modifying command configurations. + * @response 200 - `{ success: true, message: string }` + * @response 500 - Error reloading commands + * + * @example + * // Request + * POST /api/actions/reload-commands + * + * // Response + * { "success": true, "message": "Commands reloaded" } + */ + if (pathname === "/api/actions/reload-commands") { + return withErrorHandling(async () => { + const result = await actionService.reloadCommands(); + return jsonResponse(result); + }, "reload commands"); + } + + /** + * @route POST /api/actions/clear-cache + * @description Clears all internal application caches. + * Useful for forcing fresh data fetches. + * @response 200 - `{ success: true, message: string }` + * @response 500 - Error clearing cache + * + * @example + * // Request + * POST /api/actions/clear-cache + * + * // Response + * { "success": true, "message": "Cache cleared" } + */ + if (pathname === "/api/actions/clear-cache") { + return withErrorHandling(async () => { + const result = await actionService.clearCache(); + return jsonResponse(result); + }, "clear cache"); + } + + /** + * @route POST /api/actions/maintenance-mode + * @description Toggles bot maintenance mode on or off. + * When enabled, the bot will respond with a maintenance message. + * + * @body { enabled: boolean, reason?: string } + * @response 200 - `{ success: true, enabled: boolean }` + * @response 400 - Invalid payload with validation errors + * @response 500 - Error toggling maintenance mode + * + * @example + * // Request + * POST /api/actions/maintenance-mode + * Content-Type: application/json + * { "enabled": true, "reason": "Deploying updates..." } + * + * // Response + * { "success": true, "enabled": true } + */ + if (pathname === "/api/actions/maintenance-mode") { + return withErrorHandling(async () => { + const data = await parseBody(req, MaintenanceModeSchema); + if (data instanceof Response) return data; + + const result = await actionService.toggleMaintenanceMode(data.enabled, data.reason); + return jsonResponse(result); + }, "toggle maintenance mode"); + } + + return null; +} + +export const actionsRoutes: RouteModule = { + name: "actions", + handler +}; diff --git a/web/src/routes/assets.routes.ts b/web/src/routes/assets.routes.ts new file mode 100644 index 0000000..3c351fb --- /dev/null +++ b/web/src/routes/assets.routes.ts @@ -0,0 +1,83 @@ +/** + * @fileoverview Static asset serving for Aurora API. + * Serves item images and other assets from the local filesystem. + */ + +import { join, resolve, dirname } from "path"; +import type { RouteContext, RouteModule } from "./types"; + +// Resolve assets root directory +const currentDir = dirname(new URL(import.meta.url).pathname); +const assetsRoot = resolve(currentDir, "../../../bot/assets/graphics"); + +/** MIME types for supported image formats */ +const MIME_TYPES: Record = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "webp": "image/webp", + "gif": "image/gif", +}; + +/** + * Assets routes handler. + * + * Endpoints: + * - GET /assets/* - Serve static files from the assets directory + */ +async function handler(ctx: RouteContext): Promise { + const { pathname, method } = ctx; + + /** + * @route GET /assets/* + * @description Serves static asset files (images) with caching headers. + * Assets are served from the bot's graphics directory. + * + * Path security: Path traversal attacks are prevented by validating + * that the resolved path stays within the assets root. + * + * @response 200 - File content with appropriate MIME type + * @response 403 - Forbidden (path traversal attempt) + * @response 404 - File not found + * + * @example + * // Request + * GET /assets/items/1.png + * + * // Response Headers + * Content-Type: image/png + * Cache-Control: public, max-age=86400 + */ + if (pathname.startsWith("/assets/") && method === "GET") { + const assetPath = pathname.replace("/assets/", ""); + + // Security: prevent path traversal attacks + const safePath = join(assetsRoot, assetPath); + if (!safePath.startsWith(assetsRoot)) { + return new Response("Forbidden", { status: 403 }); + } + + const file = Bun.file(safePath); + if (await file.exists()) { + // Determine MIME type based on extension + const ext = safePath.split(".").pop()?.toLowerCase(); + const contentType = MIME_TYPES[ext || ""] || "application/octet-stream"; + + return new Response(file, { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=86400", // Cache for 24 hours + } + }); + } + + return new Response("Not found", { status: 404 }); + } + + return null; +} + +export const assetsRoutes: RouteModule = { + name: "assets", + handler +}; diff --git a/web/src/routes/classes.routes.ts b/web/src/routes/classes.routes.ts new file mode 100644 index 0000000..6f6612c --- /dev/null +++ b/web/src/routes/classes.routes.ts @@ -0,0 +1,155 @@ +/** + * @fileoverview Class management endpoints for Aurora API. + * Provides CRUD operations for player classes/guilds. + */ + +import type { RouteContext, RouteModule } from "./types"; +import { + jsonResponse, + errorResponse, + parseBody, + parseStringIdFromPath, + withErrorHandling +} from "./utils"; +import { CreateClassSchema, UpdateClassSchema } from "./schemas"; + +/** + * Classes routes handler. + * + * Endpoints: + * - GET /api/classes - List all classes + * - POST /api/classes - Create a new class + * - PUT /api/classes/:id - Update a class + * - DELETE /api/classes/:id - Delete a class + */ +async function handler(ctx: RouteContext): Promise { + const { pathname, method, req } = ctx; + + // Only handle requests to /api/classes* + if (!pathname.startsWith("/api/classes")) { + return null; + } + + const { classService } = await import("@shared/modules/class/class.service"); + + /** + * @route GET /api/classes + * @description Returns all classes/guilds in the system. + * + * @response 200 - `{ classes: Class[] }` + * @response 500 - Error fetching classes + * + * @example + * // Response + * { + * "classes": [ + * { "id": "1", "name": "Warrior", "balance": "5000", "roleId": "123456789" } + * ] + * } + */ + if (pathname === "/api/classes" && method === "GET") { + return withErrorHandling(async () => { + const classes = await classService.getAllClasses(); + return jsonResponse({ classes }); + }, "fetch classes"); + } + + /** + * @route POST /api/classes + * @description Creates a new class/guild. + * + * @body { + * id: string | number (required) - Unique class identifier, + * name: string (required) - Class display name, + * balance?: string | number - Initial class balance (default: 0), + * roleId?: string - Associated Discord role ID + * } + * @response 201 - `{ success: true, class: Class }` + * @response 400 - Missing required fields + * @response 500 - Error creating class + * + * @example + * // Request + * POST /api/classes + * { "id": "2", "name": "Mage", "balance": "0", "roleId": "987654321" } + */ + if (pathname === "/api/classes" && method === "POST") { + return withErrorHandling(async () => { + const data = await req.json() as Record; + + if (!data.id || !data.name || typeof data.name !== 'string') { + return errorResponse("Missing required fields: id and name are required", 400); + } + + const newClass = await classService.createClass({ + id: BigInt(data.id), + name: data.name, + balance: data.balance ? BigInt(data.balance) : 0n, + roleId: data.roleId || null, + }); + + return jsonResponse({ success: true, class: newClass }, 201); + }, "create class"); + } + + /** + * @route PUT /api/classes/:id + * @description Updates an existing class. + * + * @param id - Class ID + * @body { + * name?: string - Updated class name, + * balance?: string | number - Updated balance, + * roleId?: string - Updated Discord role ID + * } + * @response 200 - `{ success: true, class: Class }` + * @response 404 - Class not found + * @response 500 - Error updating class + */ + if (pathname.match(/^\/api\/classes\/\d+$/) && method === "PUT") { + const id = parseStringIdFromPath(pathname); + if (!id) return null; + + return withErrorHandling(async () => { + const data = await req.json() as Record; + + const updateData: any = {}; + if (data.name !== undefined) updateData.name = data.name; + if (data.balance !== undefined) updateData.balance = BigInt(data.balance); + if (data.roleId !== undefined) updateData.roleId = data.roleId; + + const updatedClass = await classService.updateClass(BigInt(id), updateData); + + if (!updatedClass) { + return errorResponse("Class not found", 404); + } + + return jsonResponse({ success: true, class: updatedClass }); + }, "update class"); + } + + /** + * @route DELETE /api/classes/:id + * @description Deletes a class. Users assigned to this class will need to be reassigned. + * + * @param id - Class ID + * @response 204 - Class deleted (no content) + * @response 500 - Error deleting class + */ + if (pathname.match(/^\/api\/classes\/\d+$/) && method === "DELETE") { + const id = parseStringIdFromPath(pathname); + if (!id) return null; + + return withErrorHandling(async () => { + await classService.deleteClass(BigInt(id)); + return new Response(null, { status: 204 }); + }, "delete class"); + } + + return null; +} + +export const classesRoutes: RouteModule = { + name: "classes", + handler +}; diff --git a/web/src/routes/health.routes.ts b/web/src/routes/health.routes.ts new file mode 100644 index 0000000..6bb082c --- /dev/null +++ b/web/src/routes/health.routes.ts @@ -0,0 +1,36 @@ +/** + * @fileoverview Health check endpoint for Aurora API. + * Provides a simple health status endpoint for monitoring and load balancers. + */ + +import type { RouteContext, RouteModule } from "./types"; + +/** + * Health routes handler. + * + * @route GET /api/health + * @description Returns server health status with timestamp. + * @response 200 - `{ status: "ok", timestamp: number }` + * + * @example + * // Request + * GET /api/health + * + * // Response + * { "status": "ok", "timestamp": 1707408000000 } + */ +async function handler(ctx: RouteContext): Promise { + if (ctx.pathname === "/api/health" && ctx.method === "GET") { + return Response.json({ + status: "ok", + timestamp: Date.now() + }); + } + + return null; +} + +export const healthRoutes: RouteModule = { + name: "health", + handler +}; diff --git a/web/src/routes/index.ts b/web/src/routes/index.ts new file mode 100644 index 0000000..216d06f --- /dev/null +++ b/web/src/routes/index.ts @@ -0,0 +1,76 @@ +/** + * @fileoverview Route registration module for Aurora API. + * Aggregates all route handlers and provides a unified request handler. + */ + +import type { RouteContext, RouteModule } from "./types"; +import { healthRoutes } from "./health.routes"; +import { statsRoutes } from "./stats.routes"; +import { actionsRoutes } from "./actions.routes"; +import { questsRoutes } from "./quests.routes"; +import { settingsRoutes } from "./settings.routes"; +import { itemsRoutes } from "./items.routes"; +import { usersRoutes } from "./users.routes"; +import { classesRoutes } from "./classes.routes"; +import { moderationRoutes } from "./moderation.routes"; +import { transactionsRoutes } from "./transactions.routes"; +import { lootdropsRoutes } from "./lootdrops.routes"; +import { assetsRoutes } from "./assets.routes"; + +/** + * All registered route modules in order of precedence. + * Routes are checked in order; the first matching route wins. + */ +const routeModules: RouteModule[] = [ + healthRoutes, + statsRoutes, + actionsRoutes, + questsRoutes, + settingsRoutes, + itemsRoutes, + usersRoutes, + classesRoutes, + moderationRoutes, + transactionsRoutes, + lootdropsRoutes, + assetsRoutes, +]; + +/** + * Main request handler that routes requests to appropriate handlers. + * + * @param req - The incoming HTTP request + * @param url - Parsed URL object + * @returns Response from matching route handler, or null if no match + * + * @example + * const response = await handleRequest(req, url); + * if (response) return response; + * return new Response("Not Found", { status: 404 }); + */ +export async function handleRequest(req: Request, url: URL): Promise { + const ctx: RouteContext = { + req, + url, + method: req.method, + pathname: url.pathname, + }; + + // Try each route module in order + for (const module of routeModules) { + const response = await module.handler(ctx); + if (response !== null) { + return response; + } + } + + return null; +} + +/** + * Get list of all registered route module names. + * Useful for debugging and documentation. + */ +export function getRegisteredRoutes(): string[] { + return routeModules.map(m => m.name); +} diff --git a/web/src/routes/items.routes.ts b/web/src/routes/items.routes.ts new file mode 100644 index 0000000..0e05c62 --- /dev/null +++ b/web/src/routes/items.routes.ts @@ -0,0 +1,366 @@ +/** + * @fileoverview Items management endpoints for Aurora API. + * Provides CRUD operations for game items with image upload support. + */ + +import { join, resolve, dirname } from "path"; +import type { RouteContext, RouteModule } from "./types"; +import { + jsonResponse, + errorResponse, + parseBody, + parseIdFromPath, + parseQuery, + withErrorHandling +} from "./utils"; +import { CreateItemSchema, UpdateItemSchema, ItemQuerySchema } from "./schemas"; + +// Resolve assets directory path +const currentDir = dirname(new URL(import.meta.url).pathname); +const assetsDir = resolve(currentDir, "../../../bot/assets/graphics/items"); + +/** + * Validates image file by checking magic bytes. + * Supports PNG, JPEG, WebP, and GIF formats. + */ +function validateImageFormat(bytes: Uint8Array): boolean { + const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47; + const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF; + const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50; + const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46; + + return isPNG || isJPEG || isWebP || isGIF; +} + +/** Maximum image file size: 15MB */ +const MAX_IMAGE_SIZE = 15 * 1024 * 1024; + +/** + * Items routes handler. + * + * Endpoints: + * - GET /api/items - List items with filters + * - POST /api/items - Create item (JSON or multipart with image) + * - GET /api/items/:id - Get single item + * - PUT /api/items/:id - Update item + * - DELETE /api/items/:id - Delete item and asset + * - POST /api/items/:id/icon - Upload/replace item icon + */ +async function handler(ctx: RouteContext): Promise { + const { pathname, method, req, url } = ctx; + + // Only handle requests to /api/items* + if (!pathname.startsWith("/api/items")) { + return null; + } + + const { itemsService } = await import("@shared/modules/items/items.service"); + + /** + * @route GET /api/items + * @description Returns a paginated list of items with optional filtering. + * + * @query search - Filter by name/description (partial match) + * @query type - Filter by item type (CONSUMABLE, EQUIPMENT, etc.) + * @query rarity - Filter by rarity (C, R, SR, SSR) + * @query limit - Max results per page (default: 100, max: 100) + * @query offset - Pagination offset (default: 0) + * + * @response 200 - `{ items: Item[], total: number }` + * @response 500 - Error fetching items + * + * @example + * // Request + * GET /api/items?type=CONSUMABLE&rarity=R&limit=10 + * + * // Response + * { + * "items": [{ "id": 1, "name": "Health Potion", ... }], + * "total": 25 + * } + */ + if (pathname === "/api/items" && method === "GET") { + return withErrorHandling(async () => { + const filters = { + search: url.searchParams.get("search") || undefined, + type: url.searchParams.get("type") || undefined, + rarity: url.searchParams.get("rarity") || undefined, + limit: url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 100, + offset: url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0, + }; + + const result = await itemsService.getAllItems(filters); + return jsonResponse(result); + }, "fetch items"); + } + + /** + * @route POST /api/items + * @description Creates a new item. Supports JSON or multipart/form-data with image. + * + * @body (JSON) { + * name: string (required), + * type: string (required), + * description?: string, + * rarity?: "C" | "R" | "SR" | "SSR", + * price?: string | number, + * usageData?: object + * } + * + * @body (Multipart) { + * data: JSON string with item fields, + * image?: File (PNG, JPEG, WebP, GIF - max 15MB) + * } + * + * @response 201 - `{ success: true, item: Item }` + * @response 400 - Missing required fields or invalid image + * @response 409 - Item name already exists + * @response 500 - Error creating item + */ + if (pathname === "/api/items" && method === "POST") { + return withErrorHandling(async () => { + const contentType = req.headers.get("content-type") || ""; + + let itemData: any; + let imageFile: File | null = null; + + if (contentType.includes("multipart/form-data")) { + const formData = await req.formData(); + const jsonData = formData.get("data"); + imageFile = formData.get("image") as File | null; + + if (typeof jsonData === "string") { + itemData = JSON.parse(jsonData); + } else { + return errorResponse("Missing item data", 400); + } + } else { + itemData = await req.json(); + } + + // Validate required fields + if (!itemData.name || !itemData.type) { + return errorResponse("Missing required fields: name and type are required", 400); + } + + // Check for duplicate name + if (await itemsService.isNameTaken(itemData.name)) { + return errorResponse("An item with this name already exists", 409); + } + + // Set placeholder URLs if image will be uploaded + const placeholderUrl = "/assets/items/placeholder.png"; + const createData = { + name: itemData.name, + description: itemData.description || null, + rarity: itemData.rarity || "C", + type: itemData.type, + price: itemData.price ? BigInt(itemData.price) : null, + iconUrl: itemData.iconUrl || placeholderUrl, + imageUrl: itemData.imageUrl || placeholderUrl, + usageData: itemData.usageData || null, + }; + + // Create the item + const item = await itemsService.createItem(createData); + + // If image was provided, save it and update the item + if (imageFile && item) { + const buffer = await imageFile.arrayBuffer(); + const bytes = new Uint8Array(buffer); + + if (!validateImageFormat(bytes)) { + await itemsService.deleteItem(item.id); + return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400); + } + + if (buffer.byteLength > MAX_IMAGE_SIZE) { + await itemsService.deleteItem(item.id); + return errorResponse("Image file too large. Maximum size is 15MB.", 400); + } + + const fileName = `${item.id}.png`; + const filePath = join(assetsDir, fileName); + await Bun.write(filePath, buffer); + + const assetUrl = `/assets/items/${fileName}`; + await itemsService.updateItem(item.id, { + iconUrl: assetUrl, + imageUrl: assetUrl, + }); + + const updatedItem = await itemsService.getItemById(item.id); + return jsonResponse({ success: true, item: updatedItem }, 201); + } + + return jsonResponse({ success: true, item }, 201); + }, "create item"); + } + + /** + * @route GET /api/items/:id + * @description Returns a single item by ID. + * + * @param id - Item ID (numeric) + * @response 200 - Full item object + * @response 404 - Item not found + * @response 500 - Error fetching item + */ + if (pathname.match(/^\/api\/items\/\d+$/) && method === "GET") { + const id = parseIdFromPath(pathname); + if (!id) return null; + + return withErrorHandling(async () => { + const item = await itemsService.getItemById(id); + if (!item) { + return errorResponse("Item not found", 404); + } + return jsonResponse(item); + }, "fetch item"); + } + + /** + * @route PUT /api/items/:id + * @description Updates an existing item. + * + * @param id - Item ID (numeric) + * @body Partial item fields to update + * @response 200 - `{ success: true, item: Item }` + * @response 404 - Item not found + * @response 409 - Name already taken by another item + * @response 500 - Error updating item + */ + if (pathname.match(/^\/api\/items\/\d+$/) && method === "PUT") { + const id = parseIdFromPath(pathname); + if (!id) return null; + + return withErrorHandling(async () => { + const data = await req.json() as Record; + + const existing = await itemsService.getItemById(id); + if (!existing) { + return errorResponse("Item not found", 404); + } + + // Check for duplicate name (if name is being changed) + if (data.name && data.name !== existing.name) { + if (await itemsService.isNameTaken(data.name, id)) { + return errorResponse("An item with this name already exists", 409); + } + } + + // Build update data + const updateData: any = {}; + if (data.name !== undefined) updateData.name = data.name; + if (data.description !== undefined) updateData.description = data.description; + if (data.rarity !== undefined) updateData.rarity = data.rarity; + if (data.type !== undefined) updateData.type = data.type; + if (data.price !== undefined) updateData.price = data.price ? BigInt(data.price) : null; + if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl; + if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl; + if (data.usageData !== undefined) updateData.usageData = data.usageData; + + const updatedItem = await itemsService.updateItem(id, updateData); + return jsonResponse({ success: true, item: updatedItem }); + }, "update item"); + } + + /** + * @route DELETE /api/items/:id + * @description Deletes an item and its associated asset file. + * + * @param id - Item ID (numeric) + * @response 204 - Item deleted (no content) + * @response 404 - Item not found + * @response 500 - Error deleting item + */ + if (pathname.match(/^\/api\/items\/\d+$/) && method === "DELETE") { + const id = parseIdFromPath(pathname); + if (!id) return null; + + return withErrorHandling(async () => { + const existing = await itemsService.getItemById(id); + if (!existing) { + return errorResponse("Item not found", 404); + } + + await itemsService.deleteItem(id); + + // Try to delete associated asset file + const assetPath = join(assetsDir, `${id}.png`); + try { + const assetFile = Bun.file(assetPath); + if (await assetFile.exists()) { + const { unlink } = await import("node:fs/promises"); + await unlink(assetPath); + } + } catch (e) { + // Non-critical: log but don't fail + const { logger } = await import("@shared/lib/logger"); + logger.warn("web", `Could not delete asset file for item ${id}`, e); + } + + return new Response(null, { status: 204 }); + }, "delete item"); + } + + /** + * @route POST /api/items/:id/icon + * @description Uploads or replaces an item's icon image. + * + * @param id - Item ID (numeric) + * @body (Multipart) { image: File } + * @response 200 - `{ success: true, item: Item }` + * @response 400 - No image file or invalid format + * @response 404 - Item not found + * @response 500 - Error uploading icon + */ + if (pathname.match(/^\/api\/items\/\d+\/icon$/) && method === "POST") { + const id = parseInt(pathname.split("/")[3] || "0"); + if (!id) return null; + + return withErrorHandling(async () => { + const existing = await itemsService.getItemById(id); + if (!existing) { + return errorResponse("Item not found", 404); + } + + const formData = await req.formData(); + const imageFile = formData.get("image") as File | null; + + if (!imageFile) { + return errorResponse("No image file provided", 400); + } + + const buffer = await imageFile.arrayBuffer(); + const bytes = new Uint8Array(buffer); + + if (!validateImageFormat(bytes)) { + return errorResponse("Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed.", 400); + } + + if (buffer.byteLength > MAX_IMAGE_SIZE) { + return errorResponse("Image file too large. Maximum size is 15MB.", 400); + } + + const fileName = `${id}.png`; + const filePath = join(assetsDir, fileName); + await Bun.write(filePath, buffer); + + const assetUrl = `/assets/items/${fileName}`; + const updatedItem = await itemsService.updateItem(id, { + iconUrl: assetUrl, + imageUrl: assetUrl, + }); + + return jsonResponse({ success: true, item: updatedItem }); + }, "upload item icon"); + } + + return null; +} + +export const itemsRoutes: RouteModule = { + name: "items", + handler +}; diff --git a/web/src/routes/lootdrops.routes.ts b/web/src/routes/lootdrops.routes.ts new file mode 100644 index 0000000..f528860 --- /dev/null +++ b/web/src/routes/lootdrops.routes.ts @@ -0,0 +1,130 @@ +/** + * @fileoverview Lootdrop management endpoints for Aurora API. + * Provides endpoints for viewing, spawning, and canceling lootdrops. + */ + +import type { RouteContext, RouteModule } from "./types"; +import { + jsonResponse, + errorResponse, + parseStringIdFromPath, + withErrorHandling +} from "./utils"; + +/** + * Lootdrops routes handler. + * + * Endpoints: + * - GET /api/lootdrops - List lootdrops + * - POST /api/lootdrops - Spawn a lootdrop + * - DELETE /api/lootdrops/:messageId - Cancel/delete a lootdrop + */ +async function handler(ctx: RouteContext): Promise { + const { pathname, method, req, url } = ctx; + + // Only handle requests to /api/lootdrops* + if (!pathname.startsWith("/api/lootdrops")) { + return null; + } + + /** + * @route GET /api/lootdrops + * @description Returns recent lootdrops, sorted by newest first. + * + * @query limit - Max results (default: 50) + * @response 200 - `{ lootdrops: Lootdrop[] }` + * @response 500 - Error fetching lootdrops + */ + if (pathname === "/api/lootdrops" && method === "GET") { + return withErrorHandling(async () => { + const { lootdrops } = await import("@shared/db/schema"); + const { DrizzleClient } = await import("@shared/db/DrizzleClient"); + const { desc } = await import("drizzle-orm"); + + const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50; + + const result = await DrizzleClient.select() + .from(lootdrops) + .orderBy(desc(lootdrops.createdAt)) + .limit(limit); + + return jsonResponse({ lootdrops: result }); + }, "fetch lootdrops"); + } + + /** + * @route POST /api/lootdrops + * @description Spawns a new lootdrop in a Discord channel. + * Requires a valid text channel ID where the bot has permissions. + * + * @body { + * channelId: string (required) - Discord channel ID to spawn in, + * amount?: number - Reward amount (random if not specified), + * currency?: string - Currency type + * } + * @response 201 - `{ success: true }` + * @response 400 - Invalid channel or missing channelId + * @response 500 - Error spawning lootdrop + * + * @example + * // Request + * POST /api/lootdrops + * { "channelId": "1234567890", "amount": 100, "currency": "Gold" } + */ + if (pathname === "/api/lootdrops" && method === "POST") { + return withErrorHandling(async () => { + const { lootdropService } = await import("@shared/modules/economy/lootdrop.service"); + const { AuroraClient } = await import("../../../bot/lib/BotClient"); + const { TextChannel } = await import("discord.js"); + + const data = await req.json() as Record; + + if (!data.channelId) { + return errorResponse("Missing required field: channelId", 400); + } + + const channel = await AuroraClient.channels.fetch(data.channelId); + + if (!channel || !(channel instanceof TextChannel)) { + return errorResponse("Invalid channel. Must be a TextChannel.", 400); + } + + await lootdropService.spawnLootdrop(channel, data.amount, data.currency); + + return jsonResponse({ success: true }, 201); + }, "spawn lootdrop"); + } + + /** + * @route DELETE /api/lootdrops/:messageId + * @description Cancels and deletes an active lootdrop. + * The lootdrop is identified by its Discord message ID. + * + * @param messageId - Discord message ID of the lootdrop + * @response 204 - Lootdrop deleted (no content) + * @response 404 - Lootdrop not found + * @response 500 - Error deleting lootdrop + */ + if (pathname.match(/^\/api\/lootdrops\/[^\/]+$/) && method === "DELETE") { + const messageId = parseStringIdFromPath(pathname); + if (!messageId) return null; + + return withErrorHandling(async () => { + const { lootdropService } = await import("@shared/modules/economy/lootdrop.service"); + const success = await lootdropService.deleteLootdrop(messageId); + + if (!success) { + return errorResponse("Lootdrop not found", 404); + } + + return new Response(null, { status: 204 }); + }, "delete lootdrop"); + } + + return null; +} + +export const lootdropsRoutes: RouteModule = { + name: "lootdrops", + handler +}; diff --git a/web/src/routes/moderation.routes.ts b/web/src/routes/moderation.routes.ts new file mode 100644 index 0000000..e898b99 --- /dev/null +++ b/web/src/routes/moderation.routes.ts @@ -0,0 +1,217 @@ +/** + * @fileoverview Moderation case management endpoints for Aurora API. + * Provides endpoints for viewing, creating, and resolving moderation cases. + */ + +import type { RouteContext, RouteModule } from "./types"; +import { + jsonResponse, + errorResponse, + parseBody, + withErrorHandling +} from "./utils"; +import { CreateCaseSchema, ClearCaseSchema, CaseIdPattern } from "./schemas"; + +/** + * Moderation routes handler. + * + * Endpoints: + * - GET /api/moderation - List cases with filters + * - GET /api/moderation/:caseId - Get single case + * - POST /api/moderation - Create new case + * - PUT /api/moderation/:caseId/clear - Clear/resolve case + */ +async function handler(ctx: RouteContext): Promise { + const { pathname, method, req, url } = ctx; + + // Only handle requests to /api/moderation* + if (!pathname.startsWith("/api/moderation")) { + return null; + } + + const { ModerationService } = await import("@shared/modules/moderation/moderation.service"); + + /** + * @route GET /api/moderation + * @description Returns moderation cases with optional filtering. + * + * @query userId - Filter by target user ID + * @query moderatorId - Filter by moderator ID + * @query type - Filter by case type (warn, timeout, kick, ban, note, prune) + * @query active - Filter by active status (true/false) + * @query limit - Max results (default: 50) + * @query offset - Pagination offset (default: 0) + * + * @response 200 - `{ cases: ModerationCase[] }` + * @response 500 - Error fetching cases + * + * @example + * // Request + * GET /api/moderation?type=warn&active=true&limit=10 + * + * // Response + * { + * "cases": [ + * { + * "id": "1", + * "caseId": "CASE-0001", + * "type": "warn", + * "userId": "123456789", + * "username": "User1", + * "moderatorId": "987654321", + * "moderatorName": "Mod1", + * "reason": "Spam", + * "active": true, + * "createdAt": "2024-01-15T12:00:00Z" + * } + * ] + * } + */ + if (pathname === "/api/moderation" && method === "GET") { + return withErrorHandling(async () => { + const filter: any = {}; + if (url.searchParams.get("userId")) filter.userId = url.searchParams.get("userId"); + if (url.searchParams.get("moderatorId")) filter.moderatorId = url.searchParams.get("moderatorId"); + if (url.searchParams.get("type")) filter.type = url.searchParams.get("type"); + const activeParam = url.searchParams.get("active"); + if (activeParam !== null) filter.active = activeParam === "true"; + filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50; + filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0; + + const cases = await ModerationService.searchCases(filter); + return jsonResponse({ cases }); + }, "fetch moderation cases"); + } + + /** + * @route GET /api/moderation/:caseId + * @description Returns a single moderation case by case ID. + * Case IDs follow the format CASE-XXXX (e.g., CASE-0001). + * + * @param caseId - Case ID in CASE-XXXX format + * @response 200 - Full case object + * @response 404 - Case not found + * @response 500 - Error fetching case + */ + if (pathname.match(/^\/api\/moderation\/CASE-\d+$/i) && method === "GET") { + const caseId = pathname.split("/").pop()!.toUpperCase(); + + return withErrorHandling(async () => { + const moderationCase = await ModerationService.getCaseById(caseId); + + if (!moderationCase) { + return errorResponse("Case not found", 404); + } + + return jsonResponse(moderationCase); + }, "fetch moderation case"); + } + + /** + * @route POST /api/moderation + * @description Creates a new moderation case. + * + * @body { + * type: "warn" | "timeout" | "kick" | "ban" | "note" | "prune" (required), + * userId: string (required) - Target user's Discord ID, + * username: string (required) - Target user's username, + * moderatorId: string (required) - Moderator's Discord ID, + * moderatorName: string (required) - Moderator's username, + * reason: string (required) - Reason for the action, + * metadata?: object - Additional case metadata (e.g., duration) + * } + * @response 201 - `{ success: true, case: ModerationCase }` + * @response 400 - Missing required fields + * @response 500 - Error creating case + * + * @example + * // Request + * POST /api/moderation + * { + * "type": "warn", + * "userId": "123456789", + * "username": "User1", + * "moderatorId": "987654321", + * "moderatorName": "Mod1", + * "reason": "Rule violation", + * "metadata": { "duration": "24h" } + * } + */ + if (pathname === "/api/moderation" && method === "POST") { + return withErrorHandling(async () => { + const data = await req.json() as Record; + + if (!data.type || !data.userId || !data.username || !data.moderatorId || !data.moderatorName || !data.reason) { + return errorResponse( + "Missing required fields: type, userId, username, moderatorId, moderatorName, reason", + 400 + ); + } + + const newCase = await ModerationService.createCase({ + type: data.type, + userId: data.userId, + username: data.username, + moderatorId: data.moderatorId, + moderatorName: data.moderatorName, + reason: data.reason, + metadata: data.metadata || {}, + }); + + return jsonResponse({ success: true, case: newCase }, 201); + }, "create moderation case"); + } + + /** + * @route PUT /api/moderation/:caseId/clear + * @description Clears/resolves a moderation case. + * Sets the case as inactive and records who cleared it. + * + * @param caseId - Case ID in CASE-XXXX format + * @body { + * clearedBy: string (required) - Discord ID of user clearing the case, + * clearedByName: string (required) - Username of user clearing the case, + * reason?: string - Reason for clearing (default: "Cleared via API") + * } + * @response 200 - `{ success: true, case: ModerationCase }` + * @response 400 - Missing required fields + * @response 404 - Case not found + * @response 500 - Error clearing case + * + * @example + * // Request + * PUT /api/moderation/CASE-0001/clear + * { "clearedBy": "987654321", "clearedByName": "Admin1", "reason": "Appeal accepted" } + */ + if (pathname.match(/^\/api\/moderation\/CASE-\d+\/clear$/i) && method === "PUT") { + const caseId = (pathname.split("/")[3] || "").toUpperCase(); + + return withErrorHandling(async () => { + const data = await req.json() as Record; + + if (!data.clearedBy || !data.clearedByName) { + return errorResponse("Missing required fields: clearedBy, clearedByName", 400); + } + + const updatedCase = await ModerationService.clearCase({ + caseId, + clearedBy: data.clearedBy, + clearedByName: data.clearedByName, + reason: data.reason || "Cleared via API", + }); + + if (!updatedCase) { + return errorResponse("Case not found", 404); + } + + return jsonResponse({ success: true, case: updatedCase }); + }, "clear moderation case"); + } + + return null; +} + +export const moderationRoutes: RouteModule = { + name: "moderation", + handler +}; diff --git a/web/src/routes/quests.routes.ts b/web/src/routes/quests.routes.ts new file mode 100644 index 0000000..987cd9b --- /dev/null +++ b/web/src/routes/quests.routes.ts @@ -0,0 +1,207 @@ +/** + * @fileoverview Quest management endpoints for Aurora API. + * Provides CRUD operations for game quests. + */ + +import type { RouteContext, RouteModule } from "./types"; +import { jsonResponse, errorResponse, parseIdFromPath, withErrorHandling } from "./utils"; +import { CreateQuestSchema, UpdateQuestSchema } from "@shared/modules/quest/quest.types"; + +/** + * Quest routes handler. + * + * Endpoints: + * - GET /api/quests - List all quests + * - POST /api/quests - Create a new quest + * - PUT /api/quests/:id - Update an existing quest + * - DELETE /api/quests/:id - Delete a quest + */ +async function handler(ctx: RouteContext): Promise { + const { pathname, method, req } = ctx; + + // Only handle requests to /api/quests* + if (!pathname.startsWith("/api/quests")) { + return null; + } + + const { questService } = await import("@shared/modules/quest/quest.service"); + + /** + * @route GET /api/quests + * @description Returns all quests in the system. + * @response 200 - `{ success: true, data: Quest[] }` + * @response 500 - Error fetching quests + * + * @example + * // Response + * { + * "success": true, + * "data": [ + * { + * "id": 1, + * "name": "Daily Login", + * "description": "Login once to claim", + * "triggerEvent": "login", + * "requirements": { "target": 1 }, + * "rewards": { "xp": 50, "balance": 100 } + * } + * ] + * } + */ + if (pathname === "/api/quests" && method === "GET") { + return withErrorHandling(async () => { + const quests = await questService.getAllQuests(); + return jsonResponse({ + success: true, + data: quests.map(q => ({ + id: q.id, + name: q.name, + description: q.description, + triggerEvent: q.triggerEvent, + requirements: q.requirements, + rewards: q.rewards, + })), + }); + }, "fetch quests"); + } + + /** + * @route POST /api/quests + * @description Creates a new quest. + * + * @body { + * name: string, + * description?: string, + * triggerEvent: string, + * target: number, + * xpReward: number, + * balanceReward: number + * } + * @response 200 - `{ success: true, quest: Quest }` + * @response 400 - Validation error + * @response 500 - Error creating quest + * + * @example + * // Request + * POST /api/quests + * { + * "name": "Win 5 Battles", + * "description": "Defeat 5 enemies in combat", + * "triggerEvent": "battle_win", + * "target": 5, + * "xpReward": 200, + * "balanceReward": 500 + * } + */ + if (pathname === "/api/quests" && method === "POST") { + return withErrorHandling(async () => { + const rawData = await req.json(); + const parseResult = CreateQuestSchema.safeParse(rawData); + + if (!parseResult.success) { + return Response.json({ + error: "Invalid payload", + issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message })) + }, { status: 400 }); + } + + const data = parseResult.data; + const result = await questService.createQuest({ + name: data.name, + description: data.description || "", + triggerEvent: data.triggerEvent, + requirements: { target: data.target }, + rewards: { + xp: data.xpReward, + balance: data.balanceReward + } + }); + + return jsonResponse({ success: true, quest: result[0] }); + }, "create quest"); + } + + /** + * @route PUT /api/quests/:id + * @description Updates an existing quest by ID. + * + * @param id - Quest ID (numeric) + * @body Partial quest fields to update + * @response 200 - `{ success: true, quest: Quest }` + * @response 400 - Invalid quest ID or validation error + * @response 404 - Quest not found + * @response 500 - Error updating quest + */ + if (pathname.match(/^\/api\/quests\/\d+$/) && method === "PUT") { + const id = parseIdFromPath(pathname); + if (!id) { + return errorResponse("Invalid quest ID", 400); + } + + return withErrorHandling(async () => { + const rawData = await req.json(); + const parseResult = UpdateQuestSchema.safeParse(rawData); + + if (!parseResult.success) { + return Response.json({ + error: "Invalid payload", + issues: parseResult.error.issues.map(i => ({ path: i.path, message: i.message })) + }, { status: 400 }); + } + + const data = parseResult.data; + const result = await questService.updateQuest(id, { + ...(data.name !== undefined && { name: data.name }), + ...(data.description !== undefined && { description: data.description }), + ...(data.triggerEvent !== undefined && { triggerEvent: data.triggerEvent }), + ...(data.target !== undefined && { requirements: { target: data.target } }), + ...((data.xpReward !== undefined || data.balanceReward !== undefined) && { + rewards: { + xp: data.xpReward ?? 0, + balance: data.balanceReward ?? 0 + } + }) + }); + + if (!result || result.length === 0) { + return errorResponse("Quest not found", 404); + } + + return jsonResponse({ success: true, quest: result[0] }); + }, "update quest"); + } + + /** + * @route DELETE /api/quests/:id + * @description Deletes a quest by ID. + * + * @param id - Quest ID (numeric) + * @response 200 - `{ success: true, deleted: number }` + * @response 400 - Invalid quest ID + * @response 404 - Quest not found + * @response 500 - Error deleting quest + */ + if (pathname.match(/^\/api\/quests\/\d+$/) && method === "DELETE") { + const id = parseIdFromPath(pathname); + if (!id) { + return errorResponse("Invalid quest ID", 400); + } + + return withErrorHandling(async () => { + const result = await questService.deleteQuest(id); + + if (!result || result.length === 0) { + return errorResponse("Quest not found", 404); + } + + return jsonResponse({ success: true, deleted: (result[0] as { id: number }).id }); + }, "delete quest"); + } + + return null; +} + +export const questsRoutes: RouteModule = { + name: "quests", + handler +}; diff --git a/web/src/routes/schemas.ts b/web/src/routes/schemas.ts new file mode 100644 index 0000000..45e1e25 --- /dev/null +++ b/web/src/routes/schemas.ts @@ -0,0 +1,274 @@ +/** + * @fileoverview Centralized Zod validation schemas for all Aurora API endpoints. + * Provides type-safe request/response validation for every entity in the system. + */ + +import { z } from "zod"; + +// ============================================================================ +// Common Schemas +// ============================================================================ + +/** + * Standard pagination query parameters. + */ +export const PaginationSchema = z.object({ + limit: z.coerce.number().min(1).max(100).optional().default(50), + offset: z.coerce.number().min(0).optional().default(0), +}); + +/** + * Numeric ID parameter validation. + */ +export const NumericIdSchema = z.coerce.number().int().positive(); + +/** + * Discord snowflake ID validation (string of digits). + */ +export const SnowflakeIdSchema = z.string().regex(/^\d{17,20}$/, "Invalid Discord ID format"); + +// ============================================================================ +// Items Schemas +// ============================================================================ + +/** + * Valid item types in the system. + */ +export const ItemTypeEnum = z.enum([ + "CONSUMABLE", + "EQUIPMENT", + "MATERIAL", + "LOOTBOX", + "COLLECTIBLE", + "KEY", + "TOOL" +]); + +/** + * Valid item rarities. + */ +export const ItemRarityEnum = z.enum(["C", "R", "SR", "SSR"]); + +/** + * Query parameters for listing items. + */ +export const ItemQuerySchema = PaginationSchema.extend({ + search: z.string().optional(), + type: z.string().optional(), + rarity: z.string().optional(), +}); + +/** + * Schema for creating a new item. + */ +export const CreateItemSchema = z.object({ + name: z.string().min(1, "Name is required").max(100), + description: z.string().max(500).nullable().optional(), + rarity: ItemRarityEnum.optional().default("C"), + type: ItemTypeEnum, + price: z.union([z.string(), z.number()]).nullable().optional(), + iconUrl: z.string().optional(), + imageUrl: z.string().optional(), + usageData: z.any().nullable().optional(), +}); + +/** + * Schema for updating an existing item. + */ +export const UpdateItemSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().max(500).nullable().optional(), + rarity: ItemRarityEnum.optional(), + type: ItemTypeEnum.optional(), + price: z.union([z.string(), z.number()]).nullable().optional(), + iconUrl: z.string().optional(), + imageUrl: z.string().optional(), + usageData: z.any().nullable().optional(), +}); + +// ============================================================================ +// Users Schemas +// ============================================================================ + +/** + * Query parameters for listing users. + */ +export const UserQuerySchema = PaginationSchema.extend({ + search: z.string().optional(), + sortBy: z.enum(["balance", "level", "xp", "username"]).optional().default("balance"), + sortOrder: z.enum(["asc", "desc"]).optional().default("desc"), +}); + +/** + * Schema for updating a user. + */ +export const UpdateUserSchema = z.object({ + username: z.string().min(1).max(32).optional(), + balance: z.union([z.string(), z.number()]).optional(), + xp: z.union([z.string(), z.number()]).optional(), + level: z.coerce.number().int().min(0).optional(), + dailyStreak: z.coerce.number().int().min(0).optional(), + isActive: z.boolean().optional(), + settings: z.record(z.string(), z.any()).optional(), + classId: z.union([z.string(), z.number()]).optional(), +}); + +/** + * Schema for adding an item to user inventory. + */ +export const InventoryAddSchema = z.object({ + itemId: z.coerce.number().int().positive("Item ID is required"), + quantity: z.union([z.string(), z.number()]).refine( + (val) => BigInt(val) > 0n, + "Quantity must be positive" + ), +}); + +/** + * Query params for removing inventory items. + */ +export const InventoryRemoveQuerySchema = z.object({ + amount: z.coerce.number().int().min(1).optional().default(1), +}); + +// ============================================================================ +// Classes Schemas +// ============================================================================ + +/** + * Schema for creating a new class. + */ +export const CreateClassSchema = z.object({ + id: z.union([z.string(), z.number()]), + name: z.string().min(1, "Name is required").max(50), + balance: z.union([z.string(), z.number()]).optional().default("0"), + roleId: z.string().nullable().optional(), +}); + +/** + * Schema for updating a class. + */ +export const UpdateClassSchema = z.object({ + name: z.string().min(1).max(50).optional(), + balance: z.union([z.string(), z.number()]).optional(), + roleId: z.string().nullable().optional(), +}); + +// ============================================================================ +// Moderation Schemas +// ============================================================================ + +/** + * Valid moderation case types. + */ +export const ModerationTypeEnum = z.enum([ + "warn", + "timeout", + "kick", + "ban", + "note", + "prune" +]); + +/** + * Query parameters for searching moderation cases. + */ +export const CaseQuerySchema = PaginationSchema.extend({ + userId: z.string().optional(), + moderatorId: z.string().optional(), + type: ModerationTypeEnum.optional(), + active: z.preprocess( + (val) => val === "true" ? true : val === "false" ? false : undefined, + z.boolean().optional() + ), +}); + +/** + * Schema for creating a moderation case. + */ +export const CreateCaseSchema = z.object({ + type: ModerationTypeEnum, + userId: z.string().min(1, "User ID is required"), + username: z.string().min(1, "Username is required"), + moderatorId: z.string().min(1, "Moderator ID is required"), + moderatorName: z.string().min(1, "Moderator name is required"), + reason: z.string().min(1, "Reason is required").max(1000), + metadata: z.record(z.string(), z.any()).optional().default({}), +}); + +/** + * Schema for clearing/resolving a moderation case. + */ +export const ClearCaseSchema = z.object({ + clearedBy: z.string().min(1, "Cleared by ID is required"), + clearedByName: z.string().min(1, "Cleared by name is required"), + reason: z.string().max(500).optional().default("Cleared via API"), +}); + +/** + * Case ID pattern validation (CASE-XXXX format). + */ +export const CaseIdPattern = /^CASE-\d+$/i; + +// ============================================================================ +// Transactions Schemas +// ============================================================================ + +/** + * Query parameters for listing transactions. + */ +export const TransactionQuerySchema = PaginationSchema.extend({ + userId: z.string().optional(), + type: z.string().optional(), +}); + +// ============================================================================ +// Lootdrops Schemas +// ============================================================================ + +/** + * Query parameters for listing lootdrops. + */ +export const LootdropQuerySchema = z.object({ + limit: z.coerce.number().min(1).max(100).optional().default(50), +}); + +/** + * Schema for spawning a lootdrop. + */ +export const CreateLootdropSchema = z.object({ + channelId: z.string().min(1, "Channel ID is required"), + amount: z.coerce.number().int().positive().optional(), + currency: z.string().optional(), +}); + +// ============================================================================ +// Admin Actions Schemas +// ============================================================================ + +/** + * Schema for toggling maintenance mode. + */ +export const MaintenanceModeSchema = z.object({ + enabled: z.boolean(), + reason: z.string().max(200).optional(), +}); + +// ============================================================================ +// Type Exports +// ============================================================================ + +export type ItemQuery = z.infer; +export type CreateItem = z.infer; +export type UpdateItem = z.infer; +export type UserQuery = z.infer; +export type UpdateUser = z.infer; +export type InventoryAdd = z.infer; +export type CreateClass = z.infer; +export type UpdateClass = z.infer; +export type CaseQuery = z.infer; +export type CreateCase = z.infer; +export type ClearCase = z.infer; +export type TransactionQuery = z.infer; +export type CreateLootdrop = z.infer; +export type MaintenanceMode = z.infer; diff --git a/web/src/routes/settings.routes.ts b/web/src/routes/settings.routes.ts new file mode 100644 index 0000000..57849e0 --- /dev/null +++ b/web/src/routes/settings.routes.ts @@ -0,0 +1,158 @@ +/** + * @fileoverview Bot settings endpoints for Aurora API. + * Provides endpoints for reading and updating bot configuration, + * as well as fetching Discord metadata. + */ + +import type { RouteContext, RouteModule } from "./types"; +import { jsonResponse, errorResponse, withErrorHandling } from "./utils"; + +/** + * JSON replacer for BigInt serialization. + */ +function jsonReplacer(_key: string, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() : value; +} + +/** + * Settings routes handler. + * + * Endpoints: + * - GET /api/settings - Get current bot configuration + * - POST /api/settings - Update bot configuration (partial merge) + * - GET /api/settings/meta - Get Discord metadata (roles, channels, commands) + */ +async function handler(ctx: RouteContext): Promise { + const { pathname, method, req } = ctx; + + // Only handle requests to /api/settings* + if (!pathname.startsWith("/api/settings")) { + return null; + } + + /** + * @route GET /api/settings + * @description Returns the current bot configuration. + * Configuration includes economy settings, leveling settings, + * command toggles, and other system settings. + * @response 200 - Full configuration object + * @response 500 - Error fetching settings + * + * @example + * // Response + * { + * "economy": { "dailyReward": 100, "streakBonus": 10 }, + * "leveling": { "xpPerMessage": 15, "levelUpChannel": "123456789" }, + * "commands": { "disabled": [], "channelLocks": {} } + * } + */ + if (pathname === "/api/settings" && method === "GET") { + return withErrorHandling(async () => { + const { config } = await import("@shared/lib/config"); + return new Response(JSON.stringify(config, jsonReplacer), { + headers: { "Content-Type": "application/json" } + }); + }, "fetch settings"); + } + + /** + * @route POST /api/settings + * @description Updates bot configuration with partial merge. + * Only the provided fields will be updated; other settings remain unchanged. + * After updating, commands are automatically reloaded. + * + * @body Partial configuration object + * @response 200 - `{ success: true }` + * @response 400 - Validation error + * @response 500 - Error saving settings + * + * @example + * // Request - Only update economy daily reward + * POST /api/settings + * { "economy": { "dailyReward": 150 } } + */ + if (pathname === "/api/settings" && method === "POST") { + try { + const partialConfig = await req.json(); + const { saveConfig, config: currentConfig } = await import("@shared/lib/config"); + const { deepMerge } = await import("@shared/lib/utils"); + + // Merge partial update into current config + const mergedConfig = deepMerge(currentConfig, partialConfig); + + // saveConfig throws if validation fails + saveConfig(mergedConfig); + + const { systemEvents, EVENTS } = await import("@shared/lib/events"); + systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS); + + return jsonResponse({ success: true }); + } catch (error) { + // Return 400 for validation errors + const message = error instanceof Error ? error.message : String(error); + return errorResponse("Failed to save settings", 400, message); + } + } + + /** + * @route GET /api/settings/meta + * @description Returns Discord server metadata for settings UI. + * Provides lists of roles, channels, and registered commands. + * + * @response 200 - `{ roles: Role[], channels: Channel[], commands: Command[] }` + * @response 500 - Error fetching metadata + * + * @example + * // Response + * { + * "roles": [ + * { "id": "123456789", "name": "Admin", "color": "#FF0000" } + * ], + * "channels": [ + * { "id": "987654321", "name": "general", "type": 0 } + * ], + * "commands": [ + * { "name": "daily", "category": "economy" } + * ] + * } + */ + if (pathname === "/api/settings/meta" && method === "GET") { + return withErrorHandling(async () => { + const { AuroraClient } = await import("../../../bot/lib/BotClient"); + const { env } = await import("@shared/lib/env"); + + if (!env.DISCORD_GUILD_ID) { + return jsonResponse({ roles: [], channels: [], commands: [] }); + } + + const guild = AuroraClient.guilds.cache.get(env.DISCORD_GUILD_ID); + if (!guild) { + return jsonResponse({ roles: [], channels: [], commands: [] }); + } + + // Map roles and channels to a simplified format + const roles = guild.roles.cache + .sort((a, b) => b.position - a.position) + .map(r => ({ id: r.id, name: r.name, color: r.hexColor })); + + const channels = guild.channels.cache + .map(c => ({ id: c.id, name: c.name, type: c.type })); + + const commands = Array.from(AuroraClient.knownCommands.entries()) + .map(([name, category]) => ({ name, category })) + .sort((a, b) => { + if (a.category !== b.category) return a.category.localeCompare(b.category); + return a.name.localeCompare(b.name); + }); + + return jsonResponse({ roles, channels, commands }); + }, "fetch settings meta"); + } + + return null; +} + +export const settingsRoutes: RouteModule = { + name: "settings", + handler +}; diff --git a/web/src/routes/stats.helper.ts b/web/src/routes/stats.helper.ts new file mode 100644 index 0000000..543bffc --- /dev/null +++ b/web/src/routes/stats.helper.ts @@ -0,0 +1,94 @@ +/** + * @fileoverview Dashboard stats helper for Aurora API. + * Provides the getFullDashboardStats function used by stats routes. + */ + +import { logger } from "@shared/lib/logger"; + +/** + * Fetches comprehensive dashboard statistics. + * Aggregates data from multiple services with error isolation. + * + * @returns Full dashboard stats object including bot info, user counts, + * economy data, leaderboards, and system status. + */ +export async function getFullDashboardStats() { + // Import services (dynamic to avoid circular deps) + const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service"); + const { lootdropService } = await import("@shared/modules/economy/lootdrop.service"); + const { getClientStats } = await import("../../../bot/lib/clientStats"); + + // Fetch all data in parallel with error isolation + const results = await Promise.allSettled([ + Promise.resolve(getClientStats()), + dashboardService.getActiveUserCount(), + dashboardService.getTotalUserCount(), + dashboardService.getEconomyStats(), + dashboardService.getRecentEvents(10), + dashboardService.getTotalItems(), + dashboardService.getActiveLootdrops(), + dashboardService.getLeaderboards(), + Promise.resolve(lootdropService.getLootdropState()), + ]); + + // Helper to unwrap result or return default + const unwrap = (result: PromiseSettledResult, defaultValue: T, name: string): T => { + if (result.status === 'fulfilled') return result.value; + logger.error("web", `Failed to fetch ${name}`, result.reason); + return defaultValue; + }; + + const clientStats = unwrap(results[0], { + bot: { name: 'Aurora', avatarUrl: null, status: null }, + guilds: 0, + commandsRegistered: 0, + commandsKnown: 0, + cachedUsers: 0, + ping: 0, + uptime: 0, + lastCommandTimestamp: null + }, 'clientStats'); + + const activeUsers = unwrap(results[1], 0, 'activeUsers'); + const totalUsers = unwrap(results[2], 0, 'totalUsers'); + const economyStats = unwrap(results[3], { totalWealth: 0n, avgLevel: 0, topStreak: 0 }, 'economyStats'); + const recentEvents = unwrap(results[4], [], 'recentEvents'); + const totalItems = unwrap(results[5], 0, 'totalItems'); + const activeLootdrops = unwrap(results[6], [], 'activeLootdrops'); + const leaderboards = unwrap(results[7], { topLevels: [], topWealth: [], topNetWorth: [] }, 'leaderboards'); + const lootdropState = unwrap(results[8], undefined, 'lootdropState'); + + return { + bot: clientStats.bot, + guilds: { count: clientStats.guilds }, + users: { active: activeUsers, total: totalUsers }, + commands: { + total: clientStats.commandsKnown, + active: clientStats.commandsRegistered, + disabled: clientStats.commandsKnown - clientStats.commandsRegistered + }, + ping: { avg: clientStats.ping }, + economy: { + totalWealth: economyStats.totalWealth.toString(), + avgLevel: economyStats.avgLevel, + topStreak: economyStats.topStreak, + totalItems, + }, + recentEvents: recentEvents.map(event => ({ + ...event, + timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp, + })), + activeLootdrops: activeLootdrops.map(drop => ({ + rewardAmount: drop.rewardAmount, + currency: drop.currency, + createdAt: drop.createdAt.toISOString(), + expiresAt: drop.expiresAt ? drop.expiresAt.toISOString() : null, + // Explicitly excluding channelId/messageId to prevent sniping + })), + lootdropState, + leaderboards, + uptime: clientStats.uptime, + lastCommandTimestamp: clientStats.lastCommandTimestamp, + maintenanceMode: (await import("../../../bot/lib/BotClient")).AuroraClient.maintenanceMode, + }; +} diff --git a/web/src/routes/stats.routes.ts b/web/src/routes/stats.routes.ts new file mode 100644 index 0000000..3e2a849 --- /dev/null +++ b/web/src/routes/stats.routes.ts @@ -0,0 +1,85 @@ +/** + * @fileoverview Statistics endpoints for Aurora API. + * Provides dashboard statistics and activity aggregation data. + */ + +import type { RouteContext, RouteModule } from "./types"; +import { jsonResponse, errorResponse, withErrorHandling } from "./utils"; + +// Cache for activity stats (heavy aggregation) +let activityPromise: Promise | null = null; +let lastActivityFetch: number = 0; +const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +/** + * Stats routes handler. + * + * Endpoints: + * - GET /api/stats - Full dashboard statistics + * - GET /api/stats/activity - Activity aggregation with caching + */ +async function handler(ctx: RouteContext): Promise { + const { pathname, method } = ctx; + + /** + * @route GET /api/stats + * @description Returns comprehensive dashboard statistics including + * bot info, user counts, economy data, and leaderboards. + * @response 200 - Full dashboard stats object + * @response 500 - Error fetching statistics + */ + if (pathname === "/api/stats" && method === "GET") { + return withErrorHandling(async () => { + // Import the stats function from wherever it's defined + // This will be passed in during initialization + const { getFullDashboardStats } = await import("./stats.helper.ts"); + const stats = await getFullDashboardStats(); + return jsonResponse(stats); + }, "fetch dashboard stats"); + } + + /** + * @route GET /api/stats/activity + * @description Returns activity aggregation data with 5-minute caching. + * Heavy query, results are cached to reduce database load. + * @response 200 - Array of activity data points + * @response 500 - Error fetching activity statistics + * + * @example + * // Response + * [ + * { "date": "2024-02-08", "commands": 150, "users": 25 }, + * { "date": "2024-02-07", "commands": 200, "users": 30 } + * ] + */ + if (pathname === "/api/stats/activity" && method === "GET") { + return withErrorHandling(async () => { + const now = Date.now(); + + // If we have a valid cache, return it + if (activityPromise && (now - lastActivityFetch < ACTIVITY_CACHE_TTL)) { + const data = await activityPromise; + return jsonResponse(data); + } + + // Otherwise, trigger a new fetch (deduplicated by the promise) + if (!activityPromise || (now - lastActivityFetch >= ACTIVITY_CACHE_TTL)) { + activityPromise = (async () => { + const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service"); + return await dashboardService.getActivityAggregation(); + })(); + lastActivityFetch = now; + } + + const activity = await activityPromise; + return jsonResponse(activity); + }, "fetch activity stats"); + } + + return null; +} + +export const statsRoutes: RouteModule = { + name: "stats", + handler +}; diff --git a/web/src/routes/transactions.routes.ts b/web/src/routes/transactions.routes.ts new file mode 100644 index 0000000..822886c --- /dev/null +++ b/web/src/routes/transactions.routes.ts @@ -0,0 +1,91 @@ +/** + * @fileoverview Transaction listing endpoints for Aurora API. + * Provides read access to economy transaction history. + */ + +import type { RouteContext, RouteModule } from "./types"; +import { jsonResponse, withErrorHandling } from "./utils"; + +/** + * Transactions routes handler. + * + * Endpoints: + * - GET /api/transactions - List transactions with filters + */ +async function handler(ctx: RouteContext): Promise { + const { pathname, method, url } = ctx; + + /** + * @route GET /api/transactions + * @description Returns economy transactions with optional filtering. + * + * @query userId - Filter by user ID (Discord snowflake) + * @query type - Filter by transaction type + * @query limit - Max results (default: 50) + * @query offset - Pagination offset (default: 0) + * + * @response 200 - `{ transactions: Transaction[] }` + * @response 500 - Error fetching transactions + * + * Transaction Types: + * - DAILY_REWARD - Daily claim reward + * - TRANSFER_IN - Received from another user + * - TRANSFER_OUT - Sent to another user + * - LOOTDROP_CLAIM - Claimed lootdrop + * - SHOP_BUY - Item purchase + * - QUEST_REWARD - Quest completion reward + * + * @example + * // Request + * GET /api/transactions?userId=123456789&type=DAILY_REWARD&limit=10 + * + * // Response + * { + * "transactions": [ + * { + * "id": "1", + * "userId": "123456789", + * "amount": "100", + * "type": "DAILY_REWARD", + * "description": "Daily reward (Streak: 3)", + * "createdAt": "2024-01-15T12:00:00Z" + * } + * ] + * } + */ + if (pathname === "/api/transactions" && method === "GET") { + return withErrorHandling(async () => { + const { transactions } = await import("@shared/db/schema"); + const { DrizzleClient } = await import("@shared/db/DrizzleClient"); + const { eq, desc } = await import("drizzle-orm"); + + const userId = url.searchParams.get("userId"); + const type = url.searchParams.get("type"); + const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50; + const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0; + + let query = DrizzleClient.select().from(transactions); + + if (userId) { + query = query.where(eq(transactions.userId, BigInt(userId))) as typeof query; + } + if (type) { + query = query.where(eq(transactions.type, type)) as typeof query; + } + + const result = await query + .orderBy(desc(transactions.createdAt)) + .limit(limit) + .offset(offset); + + return jsonResponse({ transactions: result }); + }, "fetch transactions"); + } + + return null; +} + +export const transactionsRoutes: RouteModule = { + name: "transactions", + handler +}; diff --git a/web/src/routes/types.ts b/web/src/routes/types.ts new file mode 100644 index 0000000..20be688 --- /dev/null +++ b/web/src/routes/types.ts @@ -0,0 +1,94 @@ +/** + * @fileoverview Shared types for the Aurora API routing system. + * Provides type definitions for route handlers, responses, and errors. + */ + +/** + * Standard API error response structure. + */ +export interface ApiErrorResponse { + error: string; + details?: string; + issues?: Array<{ path: (string | number)[]; message: string }>; +} + +/** + * Standard API success response with optional data wrapper. + */ +export interface ApiSuccessResponse { + success: true; + [key: string]: T | true; +} + +/** + * Route context passed to all route handlers. + * Contains parsed URL information and the original request. + */ +export interface RouteContext { + /** The original HTTP request */ + req: Request; + /** Parsed URL object */ + url: URL; + /** HTTP method (GET, POST, PUT, DELETE, etc.) */ + method: string; + /** URL pathname without query string */ + pathname: string; +} + +/** + * A route handler function that processes a request and returns a response. + * Returns null if the route doesn't match, allowing the next handler to try. + */ +export type RouteHandler = (ctx: RouteContext) => Promise | Response | null; + +/** + * A route module that exports a handler function. + */ +export interface RouteModule { + /** Human-readable name for debugging */ + name: string; + /** The route handler function */ + handler: RouteHandler; +} + +/** + * Custom API error class with HTTP status code support. + */ +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number = 500, + public readonly details?: string + ) { + super(message); + this.name = 'ApiError'; + } + + /** + * Creates a 400 Bad Request error. + */ + static badRequest(message: string, details?: string): ApiError { + return new ApiError(message, 400, details); + } + + /** + * Creates a 404 Not Found error. + */ + static notFound(resource: string): ApiError { + return new ApiError(`${resource} not found`, 404); + } + + /** + * Creates a 409 Conflict error. + */ + static conflict(message: string): ApiError { + return new ApiError(message, 409); + } + + /** + * Creates a 500 Internal Server Error. + */ + static internal(message: string, details?: string): ApiError { + return new ApiError(message, 500, details); + } +} diff --git a/web/src/routes/users.routes.ts b/web/src/routes/users.routes.ts new file mode 100644 index 0000000..321e5c1 --- /dev/null +++ b/web/src/routes/users.routes.ts @@ -0,0 +1,263 @@ +/** + * @fileoverview User management endpoints for Aurora API. + * Provides CRUD operations for users and user inventory. + */ + +import type { RouteContext, RouteModule } from "./types"; +import { + jsonResponse, + errorResponse, + parseBody, + parseIdFromPath, + parseStringIdFromPath, + withErrorHandling +} from "./utils"; +import { UpdateUserSchema, InventoryAddSchema } from "./schemas"; + +/** + * Users routes handler. + * + * Endpoints: + * - GET /api/users - List users with filters + * - GET /api/users/:id - Get single user + * - PUT /api/users/:id - Update user + * - GET /api/users/:id/inventory - Get user inventory + * - POST /api/users/:id/inventory - Add item to inventory + * - DELETE /api/users/:id/inventory/:itemId - Remove item from inventory + */ +async function handler(ctx: RouteContext): Promise { + const { pathname, method, req, url } = ctx; + + // Only handle requests to /api/users* + if (!pathname.startsWith("/api/users")) { + return null; + } + + /** + * @route GET /api/users + * @description Returns a paginated list of users with optional filtering and sorting. + * + * @query search - Filter by username (partial match) + * @query sortBy - Sort field: balance, level, xp, username (default: balance) + * @query sortOrder - Sort direction: asc, desc (default: desc) + * @query limit - Max results (default: 50) + * @query offset - Pagination offset (default: 0) + * + * @response 200 - `{ users: User[], total: number }` + * @response 500 - Error fetching users + * + * @example + * // Request + * GET /api/users?sortBy=level&sortOrder=desc&limit=10 + */ + if (pathname === "/api/users" && method === "GET") { + return withErrorHandling(async () => { + const { users } = await import("@shared/db/schema"); + const { DrizzleClient } = await import("@shared/db/DrizzleClient"); + const { ilike, desc, asc, sql } = await import("drizzle-orm"); + + const search = url.searchParams.get("search") || undefined; + const sortBy = url.searchParams.get("sortBy") || "balance"; + const sortOrder = url.searchParams.get("sortOrder") || "desc"; + const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50; + const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0; + + let query = DrizzleClient.select().from(users); + + if (search) { + query = query.where(ilike(users.username, `%${search}%`)) as typeof query; + } + + const sortColumn = sortBy === "level" ? users.level : + sortBy === "xp" ? users.xp : + sortBy === "username" ? users.username : users.balance; + const orderFn = sortOrder === "asc" ? asc : desc; + + const result = await query + .orderBy(orderFn(sortColumn)) + .limit(limit) + .offset(offset); + + const countResult = await DrizzleClient.select({ count: sql`count(*)` }).from(users); + const total = Number(countResult[0]?.count || 0); + + return jsonResponse({ users: result, total }); + }, "fetch users"); + } + + /** + * @route GET /api/users/:id + * @description Returns a single user by Discord ID. + * Includes related class information if the user has a class assigned. + * + * @param id - Discord User ID (snowflake) + * @response 200 - Full user object with class relation + * @response 404 - User not found + * @response 500 - Error fetching user + * + * @example + * // Response + * { + * "id": "123456789012345678", + * "username": "Player1", + * "balance": "1000", + * "level": 5, + * "class": { "id": "1", "name": "Warrior" } + * } + */ + if (pathname.match(/^\/api\/users\/\d+$/) && method === "GET") { + const id = parseStringIdFromPath(pathname); + if (!id) return null; + + return withErrorHandling(async () => { + const { userService } = await import("@shared/modules/user/user.service"); + const user = await userService.getUserById(id); + + if (!user) { + return errorResponse("User not found", 404); + } + + return jsonResponse(user); + }, "fetch user"); + } + + /** + * @route PUT /api/users/:id + * @description Updates user fields. Only provided fields will be updated. + * + * @param id - Discord User ID (snowflake) + * @body { + * username?: string, + * balance?: string | number, + * xp?: string | number, + * level?: number, + * dailyStreak?: number, + * isActive?: boolean, + * settings?: object, + * classId?: string | number + * } + * @response 200 - `{ success: true, user: User }` + * @response 404 - User not found + * @response 500 - Error updating user + */ + if (pathname.match(/^\/api\/users\/\d+$/) && method === "PUT") { + const id = parseStringIdFromPath(pathname); + if (!id) return null; + + return withErrorHandling(async () => { + const { userService } = await import("@shared/modules/user/user.service"); + const data = await req.json() as Record; + + const existing = await userService.getUserById(id); + if (!existing) { + return errorResponse("User not found", 404); + } + + // Build update data (only allow safe fields) + const updateData: any = {}; + if (data.username !== undefined) updateData.username = data.username; + if (data.balance !== undefined) updateData.balance = BigInt(data.balance); + if (data.xp !== undefined) updateData.xp = BigInt(data.xp); + if (data.level !== undefined) updateData.level = parseInt(data.level); + if (data.dailyStreak !== undefined) updateData.dailyStreak = parseInt(data.dailyStreak); + if (data.isActive !== undefined) updateData.isActive = Boolean(data.isActive); + if (data.settings !== undefined) updateData.settings = data.settings; + if (data.classId !== undefined) updateData.classId = BigInt(data.classId); + + const updatedUser = await userService.updateUser(id, updateData); + return jsonResponse({ success: true, user: updatedUser }); + }, "update user"); + } + + /** + * @route GET /api/users/:id/inventory + * @description Returns user's inventory with item details. + * + * @param id - Discord User ID (snowflake) + * @response 200 - `{ inventory: InventoryEntry[] }` + * @response 500 - Error fetching inventory + * + * @example + * // Response + * { + * "inventory": [ + * { + * "userId": "123456789", + * "itemId": 1, + * "quantity": "5", + * "item": { "id": 1, "name": "Health Potion", ... } + * } + * ] + * } + */ + if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "GET") { + const id = pathname.split("/")[3] || "0"; + + return withErrorHandling(async () => { + const { inventoryService } = await import("@shared/modules/inventory/inventory.service"); + const inventory = await inventoryService.getInventory(id); + return jsonResponse({ inventory }); + }, "fetch inventory"); + } + + /** + * @route POST /api/users/:id/inventory + * @description Adds an item to user's inventory. + * + * @param id - Discord User ID (snowflake) + * @body { itemId: number, quantity: string | number } + * @response 201 - `{ success: true, entry: InventoryEntry }` + * @response 400 - Missing required fields + * @response 500 - Error adding item + */ + if (pathname.match(/^\/api\/users\/\d+\/inventory$/) && method === "POST") { + const id = pathname.split("/")[3] || "0"; + + return withErrorHandling(async () => { + const { inventoryService } = await import("@shared/modules/inventory/inventory.service"); + const data = await req.json() as Record; + + if (!data.itemId || !data.quantity) { + return errorResponse("Missing required fields: itemId, quantity", 400); + } + + const entry = await inventoryService.addItem(id, data.itemId, BigInt(data.quantity)); + return jsonResponse({ success: true, entry }, 201); + }, "add item to inventory"); + } + + /** + * @route DELETE /api/users/:id/inventory/:itemId + * @description Removes an item from user's inventory. + * + * @param id - Discord User ID (snowflake) + * @param itemId - Item ID to remove + * @query amount - Quantity to remove (default: 1) + * @response 204 - Item removed (no content) + * @response 500 - Error removing item + */ + if (pathname.match(/^\/api\/users\/\d+\/inventory\/\d+$/) && method === "DELETE") { + const parts = pathname.split("/"); + const userId = parts[3] || ""; + const itemId = parseInt(parts[5] || "0"); + + if (!userId) return null; + + return withErrorHandling(async () => { + const { inventoryService } = await import("@shared/modules/inventory/inventory.service"); + + const amount = url.searchParams.get("amount"); + const quantity = amount ? BigInt(amount) : 1n; + + await inventoryService.removeItem(userId, itemId, quantity); + return new Response(null, { status: 204 }); + }, "remove item from inventory"); + } + + return null; +} + +export const usersRoutes: RouteModule = { + name: "users", + handler +}; diff --git a/web/src/routes/utils.ts b/web/src/routes/utils.ts new file mode 100644 index 0000000..0cafa11 --- /dev/null +++ b/web/src/routes/utils.ts @@ -0,0 +1,213 @@ +/** + * @fileoverview Utility functions for Aurora API route handlers. + * Provides helpers for response formatting, parameter parsing, and validation. + */ + +import { z, ZodError, type ZodSchema } from "zod"; +import type { ApiErrorResponse } from "./types"; + +/** + * JSON replacer function that handles BigInt serialization. + * Converts BigInt values to strings for JSON compatibility. + */ +export function jsonReplacer(_key: string, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() : value; +} + +/** + * Creates a JSON response with proper content-type header and BigInt handling. + * + * @param data - The data to serialize as JSON + * @param status - HTTP status code (default: 200) + * @returns A Response object with JSON content + * + * @example + * return jsonResponse({ items: [...], total: 10 }); + * return jsonResponse({ success: true, item }, 201); + */ +export function jsonResponse(data: T, status: number = 200): Response { + return new Response(JSON.stringify(data, jsonReplacer), { + status, + headers: { "Content-Type": "application/json" } + }); +} + +/** + * Creates a standardized error response. + * + * @param error - Error message + * @param status - HTTP status code (default: 500) + * @param details - Optional additional error details + * @returns A Response object with error JSON + * + * @example + * return errorResponse("Item not found", 404); + * return errorResponse("Validation failed", 400, "Name is required"); + */ +export function errorResponse( + error: string, + status: number = 500, + details?: string +): Response { + const body: ApiErrorResponse = { error }; + if (details) body.details = details; + + return Response.json(body, { status }); +} + +/** + * Creates a validation error response from a ZodError. + * + * @param zodError - The ZodError from a failed parse + * @returns A 400 Response with validation issue details + */ +export function validationErrorResponse(zodError: ZodError): Response { + return Response.json( + { + error: "Invalid payload", + issues: zodError.issues.map(issue => ({ + path: issue.path, + message: issue.message + })) + }, + { status: 400 } + ); +} + +/** + * Parses and validates a request body against a Zod schema. + * + * @param req - The HTTP request + * @param schema - Zod schema to validate against + * @returns Validated data or an error Response + * + * @example + * const result = await parseBody(req, CreateItemSchema); + * if (result instanceof Response) return result; // Validation failed + * const data = result; // Type-safe validated data + */ +export async function parseBody( + req: Request, + schema: T +): Promise | Response> { + try { + const rawBody = await req.json(); + const parsed = schema.safeParse(rawBody); + + if (!parsed.success) { + return validationErrorResponse(parsed.error); + } + + return parsed.data; + } catch (e) { + return errorResponse("Invalid JSON body", 400); + } +} + +/** + * Parses query parameters against a Zod schema. + * + * @param url - The URL containing query parameters + * @param schema - Zod schema to validate against + * @returns Validated query params or an error Response + */ +export function parseQuery( + url: URL, + schema: T +): z.infer | Response { + const params: Record = {}; + url.searchParams.forEach((value, key) => { + params[key] = value; + }); + + const parsed = schema.safeParse(params); + + if (!parsed.success) { + return validationErrorResponse(parsed.error); + } + + return parsed.data; +} + +/** + * Extracts a numeric ID from a URL path segment. + * + * @param pathname - The URL pathname + * @param position - Position from the end (0 = last segment, 1 = second-to-last, etc.) + * @returns The parsed integer ID or null if invalid + * + * @example + * parseIdFromPath("/api/items/123") // returns 123 + * parseIdFromPath("/api/items/abc") // returns null + * parseIdFromPath("/api/users/456/inventory", 1) // returns 456 + */ +export function parseIdFromPath(pathname: string, position: number = 0): number | null { + const segments = pathname.split("/").filter(Boolean); + const segment = segments[segments.length - 1 - position]; + + if (!segment) return null; + + const id = parseInt(segment, 10); + return isNaN(id) ? null : id; +} + +/** + * Extracts a string ID (like Discord snowflake) from a URL path segment. + * + * @param pathname - The URL pathname + * @param position - Position from the end (0 = last segment) + * @returns The string ID or null if segment doesn't exist + */ +export function parseStringIdFromPath(pathname: string, position: number = 0): string | null { + const segments = pathname.split("/").filter(Boolean); + const segment = segments[segments.length - 1 - position]; + return segment || null; +} + +/** + * Checks if a pathname matches a pattern with optional parameter placeholders. + * + * @param pathname - The actual URL pathname + * @param pattern - The pattern to match (use :id for numeric params, :param for string params) + * @returns True if the pattern matches + * + * @example + * matchPath("/api/items/123", "/api/items/:id") // true + * matchPath("/api/items", "/api/items/:id") // false + */ +export function matchPath(pathname: string, pattern: string): boolean { + const pathParts = pathname.split("/").filter(Boolean); + const patternParts = pattern.split("/").filter(Boolean); + + if (pathParts.length !== patternParts.length) return false; + + return patternParts.every((part, i) => { + if (part.startsWith(":")) return true; // Matches any value + return part === pathParts[i]; + }); +} + +/** + * Wraps an async route handler with consistent error handling. + * Catches all errors and returns appropriate error responses. + * + * @param handler - The async handler function + * @param logContext - Context string for error logging + * @returns A wrapped handler with error handling + */ +export function withErrorHandling( + handler: () => Promise, + logContext: string +): Promise { + return handler().catch((error: unknown) => { + // Dynamic import to avoid circular dependencies + return import("@shared/lib/logger").then(({ logger }) => { + logger.error("web", `Error in ${logContext}`, error); + return errorResponse( + `Failed to ${logContext.toLowerCase()}`, + 500, + error instanceof Error ? error.message : String(error) + ); + }); + }); +} diff --git a/web/src/server.ts b/web/src/server.ts index df1fd4a..2a8e2e5 100644 --- a/web/src/server.ts +++ b/web/src/server.ts @@ -1,12 +1,16 @@ /** - * API server factory module. + * @fileoverview API server factory module. * Exports a function to create and start the API server. * This allows the server to be started in-process from the main application. + * + * Routes are organized into modular files in the ./routes directory. + * Each route module handles its own validation, business logic, and responses. */ import { serve } from "bun"; -import { join, resolve, dirname } from "path"; import { logger } from "@shared/lib/logger"; +import { handleRequest } from "./routes"; +import { getFullDashboardStats } from "./routes/stats.helper"; export interface WebServerConfig { port?: number; @@ -21,13 +25,22 @@ export interface WebServerInstance { /** * Creates and starts the API server. + * + * @param config - Server configuration options + * @param config.port - Port to listen on (default: 3000) + * @param config.hostname - Hostname to bind to (default: "localhost") + * @returns Promise resolving to server instance with stop() method + * + * @example + * const server = await createWebServer({ port: 3000, hostname: "0.0.0.0" }); + * console.log(`Server running at ${server.url}`); + * + * // To stop the server: + * await server.stop(); */ export async function createWebServer(config: WebServerConfig = {}): Promise { const { port = 3000, hostname = "localhost" } = config; - // Resolve directories for asset serving - const currentDir = dirname(new URL(import.meta.url).pathname); - // Configuration constants const MAX_CONNECTIONS = 10; const MAX_PAYLOAD_BYTES = 16384; // 16KB @@ -36,20 +49,14 @@ export async function createWebServer(config: WebServerConfig = {}): Promise | null = null; - let lastActivityFetch: number = 0; - const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes - const server = serve({ port, hostname, async fetch(req, server) { const url = new URL(req.url); - // Upgrade to WebSocket + // WebSocket upgrade handling if (url.pathname === "/ws") { - // Security Check: limit concurrent connections const currentConnections = server.pendingWebSockets; if (currentConnections >= MAX_CONNECTIONS) { logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`); @@ -61,1246 +68,19 @@ export async function createWebServer(config: WebServerConfig = {}): Promise= ACTIVITY_CACHE_TTL)) { - activityPromise = (async () => { - const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service"); - return await dashboardService.getActivityAggregation(); - })(); - lastActivityFetch = now; - } - - const activity = await activityPromise; - return Response.json(activity); - } catch (error) { - logger.error("web", "Error fetching activity stats", error); - return Response.json( - { error: "Failed to fetch activity statistics" }, - { status: 500 } - ); - } - } - - // Administrative Actions - if (url.pathname.startsWith("/api/actions/") && req.method === "POST") { - try { - const { actionService } = await import("@shared/modules/admin/action.service"); - const { MaintenanceModeSchema } = await import("@shared/modules/dashboard/dashboard.types"); - - if (url.pathname === "/api/actions/reload-commands") { - const result = await actionService.reloadCommands(); - return Response.json(result); - } - - if (url.pathname === "/api/actions/clear-cache") { - const result = await actionService.clearCache(); - return Response.json(result); - } - - if (url.pathname === "/api/actions/maintenance-mode") { - const rawBody = await req.json(); - const parsed = MaintenanceModeSchema.safeParse(rawBody); - - if (!parsed.success) { - return Response.json({ error: "Invalid payload", issues: parsed.error.issues }, { status: 400 }); - } - - const result = await actionService.toggleMaintenanceMode(parsed.data.enabled, parsed.data.reason); - return Response.json(result); - } - } catch (error) { - logger.error("web", "Error executing administrative action", error); - return Response.json( - { error: "Failed to execute administrative action" }, - { status: 500 } - ); - } - } - - // Quest Management - if (url.pathname === "/api/quests" && req.method === "POST") { - try { - const { questService } = await import("@shared/modules/quest/quest.service"); - const { CreateQuestSchema } = await import("@shared/modules/quest/quest.types"); - - const rawBody = await req.json(); - const parsed = CreateQuestSchema.safeParse(rawBody); - - if (!parsed.success) { - return Response.json({ error: "Invalid payload", issues: parsed.error.issues }, { status: 400 }); - } - - const data = parsed.data; - const result = await questService.createQuest({ - name: data.name, - description: data.description || "", - triggerEvent: data.triggerEvent, - requirements: { target: data.target }, - rewards: { - xp: data.xpReward, - balance: data.balanceReward - } - }); - - return Response.json({ success: true, quest: result[0] }); - } catch (error) { - logger.error("web", "Error creating quest", error); - return Response.json( - { error: "Failed to create quest", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - if (url.pathname === "/api/quests" && req.method === "GET") { - try { - const { questService } = await import("@shared/modules/quest/quest.service"); - const quests = await questService.getAllQuests(); - - return Response.json({ - success: true, - data: quests.map(q => ({ - id: q.id, - name: q.name, - description: q.description, - triggerEvent: q.triggerEvent, - requirements: q.requirements, - rewards: q.rewards, - })), - }); - } catch (error) { - logger.error("web", "Error fetching quests", error); - return Response.json( - { error: "Failed to fetch quests", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - if (url.pathname.startsWith("/api/quests/") && req.method === "DELETE") { - const id = parseInt(url.pathname.split("/").pop() || "0", 10); - - if (!id) { - return Response.json({ error: "Invalid quest ID" }, { status: 400 }); - } - - try { - const { questService } = await import("@shared/modules/quest/quest.service"); - const result = await questService.deleteQuest(id); - - if (!result || result.length === 0) { - return Response.json({ error: "Quest not found" }, { status: 404 }); - } - - return Response.json({ success: true, deleted: (result[0] as { id: number }).id }); - } catch (error) { - logger.error("web", "Error deleting quest", error); - return Response.json( - { error: "Failed to delete quest", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - if (url.pathname.startsWith("/api/quests/") && req.method === "PUT") { - const id = parseInt(url.pathname.split("/").pop() || "0", 10); - - if (!id) { - return Response.json({ error: "Invalid quest ID" }, { status: 400 }); - } - - try { - const { questService } = await import("@shared/modules/quest/quest.service"); - const { UpdateQuestSchema } = await import("@shared/modules/quest/quest.types"); - - const rawBody = await req.json(); - const parsed = UpdateQuestSchema.safeParse(rawBody); - - if (!parsed.success) { - return Response.json({ error: "Invalid payload", issues: parsed.error.issues }, { status: 400 }); - } - - const data = parsed.data; - const result = await questService.updateQuest(id, { - ...(data.name !== undefined && { name: data.name }), - ...(data.description !== undefined && { description: data.description }), - ...(data.triggerEvent !== undefined && { triggerEvent: data.triggerEvent }), - ...(data.target !== undefined && { requirements: { target: data.target } }), - ...((data.xpReward !== undefined || data.balanceReward !== undefined) && { - rewards: { - xp: data.xpReward ?? 0, - balance: data.balanceReward ?? 0 - } - }) - }); - - if (!result || result.length === 0) { - return Response.json({ error: "Quest not found" }, { status: 404 }); - } - - return Response.json({ success: true, quest: result[0] }); - } catch (error) { - logger.error("web", "Error updating quest", error); - return Response.json( - { error: "Failed to update quest", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // Settings Management - if (url.pathname === "/api/settings") { - try { - if (req.method === "GET") { - const { config } = await import("@shared/lib/config"); - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify(config, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } - if (req.method === "POST") { - const partialConfig = await req.json(); - const { saveConfig, config: currentConfig } = await import("@shared/lib/config"); - const { deepMerge } = await import("@shared/lib/utils"); - - // Merge partial update into current config - const mergedConfig = deepMerge(currentConfig, partialConfig); - - - // saveConfig throws if validation fails - saveConfig(mergedConfig); - - const { systemEvents, EVENTS } = await import("@shared/lib/events"); - systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS); - - return Response.json({ success: true }); - } - } catch (error) { - logger.error("web", "Settings error", error); - return Response.json( - { error: "Failed to process settings request", details: error instanceof Error ? error.message : String(error) }, - { status: 400 } - ); - } - } - - if (url.pathname === "/api/settings/meta") { - try { - const { AuroraClient } = await import("../../bot/lib/BotClient"); - const { env } = await import("@shared/lib/env"); - - if (!env.DISCORD_GUILD_ID) { - return Response.json({ roles: [], channels: [] }); - } - - const guild = AuroraClient.guilds.cache.get(env.DISCORD_GUILD_ID); - if (!guild) { - return Response.json({ roles: [], channels: [] }); - } - - // Map roles and channels to a simplified format - const roles = guild.roles.cache - .sort((a, b) => b.position - a.position) - .map(r => ({ id: r.id, name: r.name, color: r.hexColor })); - - const channels = guild.channels.cache - .map(c => ({ id: c.id, name: c.name, type: c.type })); - - const commands = Array.from(AuroraClient.knownCommands.entries()) - .map(([name, category]) => ({ name, category })) - .sort((a, b) => { - if (a.category !== b.category) return a.category.localeCompare(b.category); - return a.name.localeCompare(b.name); - }); - - return Response.json({ roles, channels, commands }); - } catch (error) { - logger.error("web", "Error fetching settings meta", error); - return Response.json( - { error: "Failed to fetch metadata" }, - { status: 500 } - ); - } - } - - // ===================================== - // Items Management API - // ===================================== - - // GET /api/items - List all items with filtering - if (url.pathname === "/api/items" && req.method === "GET") { - try { - const { itemsService } = await import("@shared/modules/items/items.service"); - - const filters = { - search: url.searchParams.get("search") || undefined, - type: url.searchParams.get("type") || undefined, - rarity: url.searchParams.get("rarity") || undefined, - limit: url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 100, - offset: url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0, - }; - - const result = await itemsService.getAllItems(filters); - const { jsonReplacer } = await import("@shared/lib/utils"); - - return new Response(JSON.stringify(result, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error fetching items", error); - return Response.json( - { error: "Failed to fetch items", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // POST /api/items - Create new item (JSON or multipart with image) - if (url.pathname === "/api/items" && req.method === "POST") { - try { - const { itemsService } = await import("@shared/modules/items/items.service"); - const contentType = req.headers.get("content-type") || ""; - - let itemData: any; - let imageFile: File | null = null; - - if (contentType.includes("multipart/form-data")) { - // Handle multipart form with optional image - const formData = await req.formData(); - const jsonData = formData.get("data"); - imageFile = formData.get("image") as File | null; - - if (typeof jsonData === "string") { - itemData = JSON.parse(jsonData); - } else { - return Response.json({ error: "Missing item data" }, { status: 400 }); - } - } else { - // JSON-only request - itemData = await req.json(); - } - - // Validate required fields - if (!itemData.name || !itemData.type) { - return Response.json( - { error: "Missing required fields: name and type are required" }, - { status: 400 } - ); - } - - // Check for duplicate name - if (await itemsService.isNameTaken(itemData.name)) { - return Response.json( - { error: "An item with this name already exists" }, - { status: 409 } - ); - } - - // Set placeholder URLs if image will be uploaded - const placeholderUrl = "/assets/items/placeholder.png"; - const createData = { - name: itemData.name, - description: itemData.description || null, - rarity: itemData.rarity || "Common", - type: itemData.type, - price: itemData.price ? BigInt(itemData.price) : null, - iconUrl: itemData.iconUrl || placeholderUrl, - imageUrl: itemData.imageUrl || placeholderUrl, - usageData: itemData.usageData || null, - }; - - // Create the item - const item = await itemsService.createItem(createData); - - // If image was provided, save it and update the item - if (imageFile && item) { - const assetsDir = resolve(currentDir, "../../bot/assets/graphics/items"); - const fileName = `${item.id}.png`; - const filePath = join(assetsDir, fileName); - - // Validate file type (check magic bytes for PNG/JPEG/WebP/GIF) - const buffer = await imageFile.arrayBuffer(); - const bytes = new Uint8Array(buffer); - - const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47; - const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF; - const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50; - const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46; - - if (!isPNG && !isJPEG && !isWebP && !isGIF) { - // Rollback: delete the created item - await itemsService.deleteItem(item.id); - return Response.json( - { error: "Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed." }, - { status: 400 } - ); - } - - // Check file size (max 15MB) - if (buffer.byteLength > 15 * 1024 * 1024) { - await itemsService.deleteItem(item.id); - return Response.json( - { error: "Image file too large. Maximum size is 15MB." }, - { status: 400 } - ); - } - - // Save the file - await Bun.write(filePath, buffer); - - // Update item with actual asset URL - const assetUrl = `/assets/items/${fileName}`; - await itemsService.updateItem(item.id, { - iconUrl: assetUrl, - imageUrl: assetUrl, - }); - - // Return item with updated URLs - const updatedItem = await itemsService.getItemById(item.id); - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ success: true, item: updatedItem }, jsonReplacer), { - status: 201, - headers: { "Content-Type": "application/json" } - }); - } - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ success: true, item }, jsonReplacer), { - status: 201, - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error creating item", error); - return Response.json( - { error: "Failed to create item", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // GET /api/items/:id - Get single item - if (url.pathname.match(/^\/api\/items\/\d+$/) && req.method === "GET") { - const id = parseInt(url.pathname.split("/").pop()!); - - try { - const { itemsService } = await import("@shared/modules/items/items.service"); - const item = await itemsService.getItemById(id); - - if (!item) { - return Response.json({ error: "Item not found" }, { status: 404 }); - } - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify(item, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error fetching item", error); - return Response.json( - { error: "Failed to fetch item", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // PUT /api/items/:id - Update item - if (url.pathname.match(/^\/api\/items\/\d+$/) && req.method === "PUT") { - const id = parseInt(url.pathname.split("/").pop()!); - - try { - const { itemsService } = await import("@shared/modules/items/items.service"); - const data = await req.json() as Record; - - // Check if item exists - const existing = await itemsService.getItemById(id); - if (!existing) { - return Response.json({ error: "Item not found" }, { status: 404 }); - } - - // Check for duplicate name (if name is being changed) - if (data.name && data.name !== existing.name) { - if (await itemsService.isNameTaken(data.name, id)) { - return Response.json( - { error: "An item with this name already exists" }, - { status: 409 } - ); - } - } - - // Build update data - const updateData: any = {}; - if (data.name !== undefined) updateData.name = data.name; - if (data.description !== undefined) updateData.description = data.description; - if (data.rarity !== undefined) updateData.rarity = data.rarity; - if (data.type !== undefined) updateData.type = data.type; - if (data.price !== undefined) updateData.price = data.price ? BigInt(data.price) : null; - if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl; - if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl; - if (data.usageData !== undefined) updateData.usageData = data.usageData; - - const updatedItem = await itemsService.updateItem(id, updateData); - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ success: true, item: updatedItem }, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error updating item", error); - return Response.json( - { error: "Failed to update item", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // DELETE /api/items/:id - Delete item - if (url.pathname.match(/^\/api\/items\/\d+$/) && req.method === "DELETE") { - const id = parseInt(url.pathname.split("/").pop()!); - - try { - const { itemsService } = await import("@shared/modules/items/items.service"); - - const existing = await itemsService.getItemById(id); - if (!existing) { - return Response.json({ error: "Item not found" }, { status: 404 }); - } - - // Delete the item - await itemsService.deleteItem(id); - - // Try to delete associated asset file - const assetsDir = resolve(currentDir, "../../bot/assets/graphics/items"); - const assetPath = join(assetsDir, `${id}.png`); - try { - const assetFile = Bun.file(assetPath); - if (await assetFile.exists()) { - await Bun.write(assetPath, ""); // Clear file - // Note: Bun doesn't have a direct delete, but we can use unlink via node:fs - const { unlink } = await import("node:fs/promises"); - await unlink(assetPath); - } - } catch (e) { - // Non-critical: log but don't fail - logger.warn("web", `Could not delete asset file for item ${id}`, e); - } - - return new Response(null, { status: 204 }); - } catch (error) { - logger.error("web", "Error deleting item", error); - return Response.json( - { error: "Failed to delete item", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // POST /api/items/:id/icon - Upload/update item icon - if (url.pathname.match(/^\/api\/items\/\d+\/icon$/) && req.method === "POST") { - const id = parseInt(url.pathname.split("/")[3] || "0"); - - try { - const { itemsService } = await import("@shared/modules/items/items.service"); - - // Check if item exists - const existing = await itemsService.getItemById(id); - if (!existing) { - return Response.json({ error: "Item not found" }, { status: 404 }); - } - - // Parse multipart form - const formData = await req.formData(); - const imageFile = formData.get("image") as File | null; - - if (!imageFile) { - return Response.json({ error: "No image file provided" }, { status: 400 }); - } - - // Validate file type - const buffer = await imageFile.arrayBuffer(); - const bytes = new Uint8Array(buffer); - - const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47; - const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF; - const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50; - const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46; - - if (!isPNG && !isJPEG && !isWebP && !isGIF) { - return Response.json( - { error: "Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed." }, - { status: 400 } - ); - } - - // Check file size (max 15MB) - if (buffer.byteLength > 15 * 1024 * 1024) { - return Response.json( - { error: "Image file too large. Maximum size is 15MB." }, - { status: 400 } - ); - } - - // Save the file - const assetsDir = resolve(currentDir, "../../bot/assets/graphics/items"); - const fileName = `${id}.png`; - const filePath = join(assetsDir, fileName); - await Bun.write(filePath, buffer); - - // Update item with new icon URL - const assetUrl = `/assets/items/${fileName}`; - const updatedItem = await itemsService.updateItem(id, { - iconUrl: assetUrl, - imageUrl: assetUrl, - }); - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ success: true, item: updatedItem }, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error uploading item icon", error); - return Response.json( - { error: "Failed to upload icon", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // ===================================== - // Static Asset Serving (/assets/*) - // ===================================== - if (url.pathname.startsWith("/assets/")) { - const assetsRoot = resolve(currentDir, "../../bot/assets/graphics"); - const assetPath = url.pathname.replace("/assets/", ""); - - // Security: prevent path traversal - const safePath = join(assetsRoot, assetPath); - if (!safePath.startsWith(assetsRoot)) { - return new Response("Forbidden", { status: 403 }); - } - - const file = Bun.file(safePath); - if (await file.exists()) { - // Determine MIME type based on extension - const ext = safePath.split(".").pop()?.toLowerCase(); - const mimeTypes: Record = { - "png": "image/png", - "jpg": "image/jpeg", - "jpeg": "image/jpeg", - "webp": "image/webp", - "gif": "image/gif", - }; - const contentType = mimeTypes[ext || ""] || "application/octet-stream"; - - return new Response(file, { - headers: { - "Content-Type": contentType, - "Cache-Control": "public, max-age=86400", // Cache for 24 hours - } - }); - } - - return new Response("Not found", { status: 404 }); - } - - // ===================================== - // Users Management API - // ===================================== - - // GET /api/users - List all users with filtering - if (url.pathname === "/api/users" && req.method === "GET") { - try { - const { users } = await import("../../shared/db/schema"); - const { DrizzleClient } = await import("@shared/db/DrizzleClient"); - const { ilike, desc, asc, sql } = await import("drizzle-orm"); - - const search = url.searchParams.get("search") || undefined; - const sortBy = url.searchParams.get("sortBy") || "balance"; - const sortOrder = url.searchParams.get("sortOrder") || "desc"; - const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50; - const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0; - - let query = DrizzleClient.select().from(users); - - if (search) { - query = query.where(ilike(users.username, `%${search}%`)) as typeof query; - } - - // Build order clause - const sortColumn = sortBy === "level" ? users.level : - sortBy === "xp" ? users.xp : - sortBy === "username" ? users.username : users.balance; - const orderFn = sortOrder === "asc" ? asc : desc; - - const result = await query - .orderBy(orderFn(sortColumn)) - .limit(limit) - .offset(offset); - - // Get total count - const countResult = await DrizzleClient.select({ count: sql`count(*)` }).from(users); - const total = Number(countResult[0]?.count || 0); - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ users: result, total }, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error fetching users", error); - return Response.json( - { error: "Failed to fetch users", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // GET /api/users/:id - Get single user - if (url.pathname.match(/^\/api\/users\/\d+$/) && req.method === "GET") { - const id = url.pathname.split("/").pop()!; - - try { - const { userService } = await import("@shared/modules/user/user.service"); - const user = await userService.getUserById(id); - - if (!user) { - return Response.json({ error: "User not found" }, { status: 404 }); - } - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify(user, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error fetching user", error); - return Response.json( - { error: "Failed to fetch user", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // PUT /api/users/:id - Update user - if (url.pathname.match(/^\/api\/users\/\d+$/) && req.method === "PUT") { - const id = url.pathname.split("/").pop()!; - - try { - const { userService } = await import("@shared/modules/user/user.service"); - const data = await req.json() as Record; - - // Check if user exists - const existing = await userService.getUserById(id); - if (!existing) { - return Response.json({ error: "User not found" }, { status: 404 }); - } - - // Build update data (only allow safe fields) - const updateData: any = {}; - if (data.username !== undefined) updateData.username = data.username; - if (data.balance !== undefined) updateData.balance = BigInt(data.balance); - if (data.xp !== undefined) updateData.xp = BigInt(data.xp); - if (data.level !== undefined) updateData.level = parseInt(data.level); - if (data.dailyStreak !== undefined) updateData.dailyStreak = parseInt(data.dailyStreak); - if (data.isActive !== undefined) updateData.isActive = Boolean(data.isActive); - if (data.settings !== undefined) updateData.settings = data.settings; - if (data.classId !== undefined) updateData.classId = BigInt(data.classId); - - const updatedUser = await userService.updateUser(id, updateData); - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ success: true, user: updatedUser }, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error updating user", error); - return Response.json( - { error: "Failed to update user", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // GET /api/users/:id/inventory - Get user inventory - if (url.pathname.match(/^\/api\/users\/\d+\/inventory$/) && req.method === "GET") { - const id = url.pathname.split("/")[3] || "0"; - - try { - const { inventoryService } = await import("@shared/modules/inventory/inventory.service"); - const inventory = await inventoryService.getInventory(id); - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ inventory }, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error fetching inventory", error); - return Response.json( - { error: "Failed to fetch inventory", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // POST /api/users/:id/inventory - Add item to inventory - if (url.pathname.match(/^\/api\/users\/\d+\/inventory$/) && req.method === "POST") { - const id = url.pathname.split("/")[3] || "0"; - - try { - const { inventoryService } = await import("@shared/modules/inventory/inventory.service"); - const data = await req.json() as Record; - - if (!data.itemId || !data.quantity) { - return Response.json( - { error: "Missing required fields: itemId, quantity" }, - { status: 400 } - ); - } - - const entry = await inventoryService.addItem(id, data.itemId, BigInt(data.quantity)); - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ success: true, entry }, jsonReplacer), { - status: 201, - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error adding item to inventory", error); - return Response.json( - { error: "Failed to add item", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // DELETE /api/users/:id/inventory/:itemId - Remove item from inventory - if (url.pathname.match(/^\/api\/users\/\d+\/inventory\/\d+$/) && req.method === "DELETE") { - const parts = url.pathname.split("/"); - const userId = parts[3]; - const itemId = parseInt(parts[5]); - - try { - const { inventoryService } = await import("@shared/modules/inventory/inventory.service"); - - const amount = url.searchParams.get("amount"); - const quantity = amount ? BigInt(amount) : 1n; - - await inventoryService.removeItem(userId, itemId, quantity); - - return new Response(null, { status: 204 }); - } catch (error) { - logger.error("web", "Error removing item from inventory", error); - return Response.json( - { error: "Failed to remove item", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // ===================================== - // Classes Management API - // ===================================== - - // GET /api/classes - List all classes - if (url.pathname === "/api/classes" && req.method === "GET") { - try { - const { classService } = await import("@shared/modules/class/class.service"); - const classes = await classService.getAllClasses(); - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ classes }, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error fetching classes", error); - return Response.json( - { error: "Failed to fetch classes", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // POST /api/classes - Create new class - if (url.pathname === "/api/classes" && req.method === "POST") { - try { - const { classService } = await import("@shared/modules/class/class.service"); - const data = await req.json() as Record; - - if (!data.id || !data.name || typeof data.name !== 'string') { - return Response.json( - { error: "Missing required fields: id and name are required" }, - { status: 400 } - ); - } - - const newClass = await classService.createClass({ - id: BigInt(data.id), - name: data.name, - balance: data.balance ? BigInt(data.balance) : 0n, - roleId: data.roleId || null, - }); - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ success: true, class: newClass }, jsonReplacer), { - status: 201, - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error creating class", error); - return Response.json( - { error: "Failed to create class", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // PUT /api/classes/:id - Update class - if (url.pathname.match(/^\/api\/classes\/\d+$/) && req.method === "PUT") { - const id = url.pathname.split("/").pop()!; - - try { - const { classService } = await import("@shared/modules/class/class.service"); - const data = await req.json() as Record; - - const updateData: any = {}; - if (data.name !== undefined) updateData.name = data.name; - if (data.balance !== undefined) updateData.balance = BigInt(data.balance); - if (data.roleId !== undefined) updateData.roleId = data.roleId; - - const updatedClass = await classService.updateClass(BigInt(id), updateData); - - if (!updatedClass) { - return Response.json({ error: "Class not found" }, { status: 404 }); - } - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ success: true, class: updatedClass }, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error updating class", error); - return Response.json( - { error: "Failed to update class", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // DELETE /api/classes/:id - Delete class - if (url.pathname.match(/^\/api\/classes\/\d+$/) && req.method === "DELETE") { - const id = url.pathname.split("/").pop()!; - - try { - const { classService } = await import("@shared/modules/class/class.service"); - await classService.deleteClass(BigInt(id)); - - return new Response(null, { status: 204 }); - } catch (error) { - logger.error("web", "Error deleting class", error); - return Response.json( - { error: "Failed to delete class", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // ===================================== - // Moderation API - // ===================================== - - // GET /api/moderation - List moderation cases - if (url.pathname === "/api/moderation" && req.method === "GET") { - try { - const { ModerationService } = await import("@shared/modules/moderation/moderation.service"); - - const filter: any = {}; - if (url.searchParams.get("userId")) filter.userId = url.searchParams.get("userId"); - if (url.searchParams.get("moderatorId")) filter.moderatorId = url.searchParams.get("moderatorId"); - if (url.searchParams.get("type")) filter.type = url.searchParams.get("type"); - const activeParam = url.searchParams.get("active"); - if (activeParam !== null) filter.active = activeParam === "true"; - filter.limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50; - filter.offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0; - - const cases = await ModerationService.searchCases(filter); - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ cases }, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error fetching moderation cases", error); - return Response.json( - { error: "Failed to fetch moderation cases", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // GET /api/moderation/:caseId - Get single case - if (url.pathname.match(/^\/api\/moderation\/CASE-\d+$/i) && req.method === "GET") { - const caseId = url.pathname.split("/").pop()!.toUpperCase(); - - try { - const { ModerationService } = await import("@shared/modules/moderation/moderation.service"); - const moderationCase = await ModerationService.getCaseById(caseId); - - if (!moderationCase) { - return Response.json({ error: "Case not found" }, { status: 404 }); - } - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify(moderationCase, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error fetching moderation case", error); - return Response.json( - { error: "Failed to fetch case", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // POST /api/moderation - Create new case - if (url.pathname === "/api/moderation" && req.method === "POST") { - try { - const { ModerationService } = await import("@shared/modules/moderation/moderation.service"); - const data = await req.json() as Record; - - if (!data.type || !data.userId || !data.username || !data.moderatorId || !data.moderatorName || !data.reason) { - return Response.json( - { error: "Missing required fields: type, userId, username, moderatorId, moderatorName, reason" }, - { status: 400 } - ); - } - - const newCase = await ModerationService.createCase({ - type: data.type, - userId: data.userId, - username: data.username, - moderatorId: data.moderatorId, - moderatorName: data.moderatorName, - reason: data.reason, - metadata: data.metadata || {}, - }); - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ success: true, case: newCase }, jsonReplacer), { - status: 201, - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error creating moderation case", error); - return Response.json( - { error: "Failed to create case", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // PUT /api/moderation/:caseId/clear - Clear/resolve a case - if (url.pathname.match(/^\/api\/moderation\/CASE-\d+\/clear$/i) && req.method === "PUT") { - const caseId = (url.pathname.split("/")[3] || "").toUpperCase(); - - try { - const { ModerationService } = await import("@shared/modules/moderation/moderation.service"); - const data = await req.json() as Record; - - if (!data.clearedBy || !data.clearedByName) { - return Response.json( - { error: "Missing required fields: clearedBy, clearedByName" }, - { status: 400 } - ); - } - - const updatedCase = await ModerationService.clearCase({ - caseId, - clearedBy: data.clearedBy, - clearedByName: data.clearedByName, - reason: data.reason || "Cleared via API", - }); - - if (!updatedCase) { - return Response.json({ error: "Case not found" }, { status: 404 }); - } - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ success: true, case: updatedCase }, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error clearing moderation case", error); - return Response.json( - { error: "Failed to clear case", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // ===================================== - // Transactions API - // ===================================== - - // GET /api/transactions - List transactions - if (url.pathname === "/api/transactions" && req.method === "GET") { - try { - const { transactions } = await import("../../shared/db/schema"); - const { DrizzleClient } = await import("@shared/db/DrizzleClient"); - const { eq, desc } = await import("drizzle-orm"); - - const userId = url.searchParams.get("userId"); - const type = url.searchParams.get("type"); - const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50; - const offset = url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0; - - let query = DrizzleClient.select().from(transactions); - - if (userId) { - query = query.where(eq(transactions.userId, BigInt(userId))) as typeof query; - } - if (type) { - query = query.where(eq(transactions.type, type)) as typeof query; - } - - const result = await query - .orderBy(desc(transactions.createdAt)) - .limit(limit) - .offset(offset); - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ transactions: result }, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error fetching transactions", error); - return Response.json( - { error: "Failed to fetch transactions", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // ===================================== - // Lootdrops API - // ===================================== - - // GET /api/lootdrops - List lootdrops - if (url.pathname === "/api/lootdrops" && req.method === "GET") { - try { - const { lootdrops } = await import("../../shared/db/schema"); - const { DrizzleClient } = await import("@shared/db/DrizzleClient"); - const { desc } = await import("drizzle-orm"); - - const limit = url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 50; - - const result = await DrizzleClient.select() - .from(lootdrops) - .orderBy(desc(lootdrops.createdAt)) - .limit(limit); - - const { jsonReplacer } = await import("@shared/lib/utils"); - return new Response(JSON.stringify({ lootdrops: result }, jsonReplacer), { - headers: { "Content-Type": "application/json" } - }); - } catch (error) { - logger.error("web", "Error fetching lootdrops", error); - return Response.json( - { error: "Failed to fetch lootdrops", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // POST /api/lootdrops - Spawn lootdrop - if (url.pathname === "/api/lootdrops" && req.method === "POST") { - try { - const { lootdropService } = await import("@shared/modules/economy/lootdrop.service"); - const { AuroraClient } = await import("../../bot/lib/BotClient"); - const { TextChannel } = await import("discord.js"); - - const data = await req.json() as Record; - - if (!data.channelId) { - return Response.json( - { error: "Missing required field: channelId" }, - { status: 400 } - ); - } - - const channel = await AuroraClient.channels.fetch(data.channelId); - - if (!channel || !(channel instanceof TextChannel)) { - return Response.json( - { error: "Invalid channel. Must be a TextChannel." }, - { status: 400 } - ); - } - - await lootdropService.spawnLootdrop(channel, data.amount, data.currency); - - return Response.json({ success: true }, { status: 201 }); - } catch (error) { - logger.error("web", "Error spawning lootdrop", error); - return Response.json( - { error: "Failed to spawn lootdrop", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // DELETE /api/lootdrops/:id - Cancel/Delete lootdrop - if (url.pathname.match(/^\/api\/lootdrops\/[^\/]+$/) && req.method === "DELETE") { - const messageId = url.pathname.split("/").pop()!; - - try { - const { lootdropService } = await import("@shared/modules/economy/lootdrop.service"); - const success = await lootdropService.deleteLootdrop(messageId); - - if (!success) { - return Response.json({ error: "Lootdrop not found" }, { status: 404 }); - } - - return new Response(null, { status: 204 }); - } catch (error) { - logger.error("web", "Error deleting lootdrop", error); - return Response.json( - { error: "Failed to delete lootdrop", details: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } - } - - // No frontend - return 404 for unknown routes + // No matching route found return new Response("Not Found", { status: 404 }); }, websocket: { + /** + * Called when a WebSocket client connects. + * Subscribes the client to the dashboard channel and sends initial stats. + */ open(ws) { ws.subscribe("dashboard"); logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`); @@ -1322,6 +102,11 @@ export async function createWebServer(config: WebServerConfig = {}): Promise(result: PromiseSettledResult, defaultValue: T, name: string): T => { - if (result.status === 'fulfilled') return result.value; - logger.error("web", `Failed to fetch ${name}`, result.reason); - return defaultValue; - }; - - const clientStats = unwrap(results[0], { - bot: { name: 'Aurora', avatarUrl: null, status: null }, - guilds: 0, - commandsRegistered: 0, - commandsKnown: 0, - cachedUsers: 0, - ping: 0, - uptime: 0, - lastCommandTimestamp: null - }, 'clientStats'); - - const activeUsers = unwrap(results[1], 0, 'activeUsers'); - const totalUsers = unwrap(results[2], 0, 'totalUsers'); - const economyStats = unwrap(results[3], { totalWealth: 0n, avgLevel: 0, topStreak: 0 }, 'economyStats'); - const recentEvents = unwrap(results[4], [], 'recentEvents'); - const totalItems = unwrap(results[5], 0, 'totalItems'); - const activeLootdrops = unwrap(results[6], [], 'activeLootdrops'); - const leaderboards = unwrap(results[7], { topLevels: [], topWealth: [], topNetWorth: [] }, 'leaderboards'); - const lootdropState = unwrap(results[8], undefined, 'lootdropState'); - - return { - bot: clientStats.bot, - guilds: { count: clientStats.guilds }, - users: { active: activeUsers, total: totalUsers }, - commands: { - total: clientStats.commandsKnown, - active: clientStats.commandsRegistered, - disabled: clientStats.commandsKnown - clientStats.commandsRegistered - }, - ping: { avg: clientStats.ping }, - economy: { - totalWealth: economyStats.totalWealth.toString(), - avgLevel: economyStats.avgLevel, - topStreak: economyStats.topStreak, - totalItems, - }, - recentEvents: recentEvents.map(event => ({ - ...event, - timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp, - })), - activeLootdrops: activeLootdrops.map(drop => ({ - rewardAmount: drop.rewardAmount, - currency: drop.currency, - createdAt: drop.createdAt.toISOString(), - expiresAt: drop.expiresAt ? drop.expiresAt.toISOString() : null, - // Explicitly excluding channelId/messageId to prevent sniping - })), - lootdropState, - leaderboards, - uptime: clientStats.uptime, - lastCommandTimestamp: clientStats.lastCommandTimestamp, - maintenanceMode: (await import("../../bot/lib/BotClient")).AuroraClient.maintenanceMode, - }; - } - // Listen for real-time events from the system bus const { systemEvents, EVENTS } = await import("@shared/lib/events"); systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => { @@ -1471,13 +175,15 @@ export async function createWebServer(config: WebServerConfig = {}): Promise { - // Current implementation doesn't need CWD switching thanks to absolute path resolution return createWebServer(config); }