Some checks failed
Deploy to Production / test (push) Failing after 30s
The web/ folder contains the REST API, WebSocket server, and OAuth routes — not a web frontend. Renaming to api/ clarifies this distinction since the actual web frontend lives in panel/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
264 lines
9.5 KiB
TypeScript
264 lines
9.5 KiB
TypeScript
/**
|
|
* @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
|
|
};
|