feat: Implement a new API routing system by adding dedicated route files for users, transactions, assets, items, quests, and other game entities, and integrating them into the server.
All checks were successful
Deploy to Production / test (push) Successful in 44s

This commit is contained in:
syntaxbullet
2026-02-08 18:57:42 +01:00
parent 073348fa55
commit 553b9b4952
19 changed files with 2713 additions and 1336 deletions

View File

@@ -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<typeof CreateQuestSchema>;
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<typeof UpdateQuestSchema>;

View File

@@ -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<Response | null> {
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
};

View File

@@ -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<string, string> = {
"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<Response | null> {
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
};

View File

@@ -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<Response | null> {
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<string, any>;
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<string, any>;
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
};

View File

@@ -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<Response | null> {
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
};

76
web/src/routes/index.ts Normal file
View File

@@ -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<Response | null> {
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);
}

View File

@@ -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<Response | null> {
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<string, any>;
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
};

View File

@@ -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<Response | null> {
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<string, any>;
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
};

View File

@@ -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<Response | null> {
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<string, any>;
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<string, any>;
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
};

View File

@@ -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<Response | null> {
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
};

274
web/src/routes/schemas.ts Normal file
View File

@@ -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<typeof ItemQuerySchema>;
export type CreateItem = z.infer<typeof CreateItemSchema>;
export type UpdateItem = z.infer<typeof UpdateItemSchema>;
export type UserQuery = z.infer<typeof UserQuerySchema>;
export type UpdateUser = z.infer<typeof UpdateUserSchema>;
export type InventoryAdd = z.infer<typeof InventoryAddSchema>;
export type CreateClass = z.infer<typeof CreateClassSchema>;
export type UpdateClass = z.infer<typeof UpdateClassSchema>;
export type CaseQuery = z.infer<typeof CaseQuerySchema>;
export type CreateCase = z.infer<typeof CreateCaseSchema>;
export type ClearCase = z.infer<typeof ClearCaseSchema>;
export type TransactionQuery = z.infer<typeof TransactionQuerySchema>;
export type CreateLootdrop = z.infer<typeof CreateLootdropSchema>;
export type MaintenanceMode = z.infer<typeof MaintenanceModeSchema>;

View File

@@ -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<Response | null> {
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
};

View File

@@ -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 = <T>(result: PromiseSettledResult<T>, 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,
};
}

View File

@@ -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<import("@shared/modules/dashboard/dashboard.types").ActivityData[]> | 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<Response | null> {
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
};

View File

@@ -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<Response | null> {
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
};

94
web/src/routes/types.ts Normal file
View File

@@ -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<T = unknown> {
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> | 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);
}
}

View File

@@ -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<Response | null> {
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<number>`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<string, any>;
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<string, any>;
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
};

213
web/src/routes/utils.ts Normal file
View File

@@ -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<T>(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<T extends ZodSchema>(
req: Request,
schema: T
): Promise<z.infer<T> | 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<T extends ZodSchema>(
url: URL,
schema: T
): z.infer<T> | Response {
const params: Record<string, string> = {};
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<Response>,
logContext: string
): Promise<Response> {
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)
);
});
});
}

File diff suppressed because it is too large Load Diff