From 423267449403ef3fc52d705e2a8a6704fa94d028 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sun, 8 Feb 2026 16:55:04 +0100 Subject: [PATCH] feat: implement user inventory management and class update endpoints --- docs/api.md | 321 ++++++++++++++++++++++++++++ web/src/server.ts | 527 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 831 insertions(+), 17 deletions(-) diff --git a/docs/api.md b/docs/api.md index c5c4a6f..3927527 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2,6 +2,35 @@ REST API server for Aurora bot management. Base URL: `http://localhost:3000` +## Common Response Formats + +**Success Responses:** +- Single resource: `{ ...resource }` or `{ success: true, resource: {...} }` +- List operations: `{ items: [...], total: number }` +- Mutations: `{ success: true, resource: {...} }` + +**Error Responses:** +```json +{ + "error": "Brief error message", + "details": "Optional detailed error information" +} +``` + +**HTTP Status Codes:** +| Code | Description | +|------|-------------| +| 200 | Success | +| 201 | Created | +| 204 | No Content (successful DELETE) | +| 400 | Bad Request (validation error) | +| 404 | Not Found | +| 409 | Conflict (e.g., duplicate name) | +| 429 | Too Many Requests | +| 500 | Internal Server Error | + +--- + ## Health ### `GET /api/health` @@ -29,6 +58,21 @@ List all items with optional filtering. ### `GET /api/items/:id` Get single item by ID. +**Response:** +```json +{ + "id": 1, + "name": "Health Potion", + "description": "Restores HP", + "type": "CONSUMABLE", + "rarity": "C", + "price": "100", + "iconUrl": "/assets/items/1.png", + "imageUrl": "/assets/items/1.png", + "usageData": { "consume": true, "effects": [] } +} +``` + ### `POST /api/items` Create new item. Supports JSON or multipart/form-data with image. @@ -40,10 +84,16 @@ Create new item. Supports JSON or multipart/form-data with image. "type": "CONSUMABLE", "rarity": "C", "price": "100", + "iconUrl": "/assets/items/placeholder.png", + "imageUrl": "/assets/items/placeholder.png", "usageData": { "consume": true, "effects": [] } } ``` +**Body (Multipart):** +- `data`: JSON string with item fields +- `image`: Image file (PNG, JPEG, WebP, GIF, max 15MB) + ### `PUT /api/items/:id` Update existing item. @@ -55,11 +105,273 @@ Upload/replace item image. Accepts multipart/form-data with `image` field. --- +## Users + +### `GET /api/users` +List all users with optional filtering and sorting. + +| Query Param | Type | Description | +|-------------|------|-------------| +| `search` | string | Filter by username (partial match) | +| `sortBy` | string | Sort field: `balance`, `level`, `xp`, `username` (default: `balance`) | +| `sortOrder` | string | Sort order: `asc`, `desc` (default: `desc`) | +| `limit` | number | Max results (default: 50) | +| `offset` | number | Pagination offset | + +**Response:** `{ "users": [...], "total": number }` + +### `GET /api/users/:id` +Get single user by Discord ID. + +**Response:** +```json +{ + "id": "123456789012345678", + "username": "Player1", + "balance": "1000", + "xp": "500", + "level": 5, + "dailyStreak": 3, + "isActive": true, + "classId": "1", + "class": { "id": "1", "name": "Warrior", "balance": "5000" }, + "settings": {}, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-15T12:00:00Z" +} +``` + +### `PUT /api/users/:id` +Update user fields. + +**Body:** +```json +{ + "username": "NewName", + "balance": "2000", + "xp": "750", + "level": 10, + "dailyStreak": 5, + "classId": "1", + "isActive": true, + "settings": {} +} +``` + +### `GET /api/users/:id/inventory` +Get user's inventory with item details. + +**Response:** +```json +{ + "inventory": [ + { + "userId": "123456789012345678", + "itemId": 1, + "quantity": "5", + "item": { "id": 1, "name": "Health Potion", ... } + } + ] +} +``` + +### `POST /api/users/:id/inventory` +Add item to user inventory. + +**Body:** +```json +{ + "itemId": 1, + "quantity": "5" +} +``` + +### `DELETE /api/users/:id/inventory/:itemId` +Remove item from user inventory. Use query param `amount` to specify quantity (default: 1). + +| Query Param | Type | Description | +|-------------|------|-------------| +| `amount` | number | Amount to remove (default: 1) | +``` + +--- + +## Classes + +### `GET /api/classes` +List all classes. + +**Response:** +```json +{ + "classes": [ + { "id": "1", "name": "Warrior", "balance": "5000", "roleId": "123456789" } + ] +} +``` + +### `POST /api/classes` +Create new class. + +**Body:** +```json +{ + "id": "2", + "name": "Mage", + "balance": "0", + "roleId": "987654321" +} +``` + +### `PUT /api/classes/:id` +Update class. + +**Body:** +```json +{ + "name": "Updated Name", + "balance": "10000", + "roleId": "111222333" +} +``` + +### `DELETE /api/classes/:id` +Delete class. + +--- + +## Moderation + +### `GET /api/moderation` +List moderation cases with optional filtering. + +| Query Param | Type | Description | +|-------------|------|-------------| +| `userId` | string | Filter by target user ID | +| `moderatorId` | string | Filter by moderator ID | +| `type` | string | Filter by case type: `warn`, `timeout`, `kick`, `ban`, `note`, `prune` | +| `active` | boolean | Filter by active status | +| `limit` | number | Max results (default: 50) | +| `offset` | number | Pagination offset | + +**Response:** +```json +{ + "cases": [ + { + "id": "1", + "caseId": "CASE-0001", + "type": "warn", + "userId": "123456789", + "username": "User1", + "moderatorId": "987654321", + "moderatorName": "Mod1", + "reason": "Spam", + "metadata": {}, + "active": true, + "createdAt": "2024-01-15T12:00:00Z", + "resolvedAt": null, + "resolvedBy": null, + "resolvedReason": null + } + ] +} +``` + +### `GET /api/moderation/:caseId` +Get single case by case ID (e.g., `CASE-0001`). + +### `POST /api/moderation` +Create new moderation case. + +**Body:** +```json +{ + "type": "warn", + "userId": "123456789", + "username": "User1", + "moderatorId": "987654321", + "moderatorName": "Mod1", + "reason": "Rule violation", + "metadata": { "duration": "24h" } +} +``` + +### `PUT /api/moderation/:caseId/clear` +Clear/resolve a moderation case. + +**Body:** +```json +{ + "clearedBy": "987654321", + "clearedByName": "Mod1", + "reason": "Appeal accepted" +} +``` + +--- + +## Transactions + +### `GET /api/transactions` +List economy transactions. + +| Query Param | Type | Description | +|-------------|------|-------------| +| `userId` | string | Filter by user ID | +| `type` | string | Filter by transaction type | +| `limit` | number | Max results (default: 50) | +| `offset` | number | Pagination offset | + +**Response:** +```json +{ + "transactions": [ + { + "id": "1", + "userId": "123456789", + "relatedUserId": null, + "amount": "100", + "type": "DAILY_REWARD", + "description": "Daily reward (Streak: 3)", + "createdAt": "2024-01-15T12:00:00Z" + } + ] +} +``` + +**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 + +--- + ## Quests ### `GET /api/quests` List all quests. +**Response:** +```json +{ + "success": true, + "data": [ + { + "id": 1, + "name": "Daily Login", + "description": "Login once", + "triggerEvent": "login", + "requirements": { "target": 1 }, + "rewards": { "xp": 50, "balance": 100 } + } + ] +} +``` + ### `POST /api/quests` Create new quest. @@ -94,6 +406,15 @@ Update configuration (partial merge supported). ### `GET /api/settings/meta` Get Discord metadata (roles, channels, commands). +**Response:** +```json +{ + "roles": [{ "id": "123", "name": "Admin", "color": "#FF0000" }], + "channels": [{ "id": "456", "name": "general", "type": 0 }], + "commands": [{ "name": "daily", "category": "economy" }] +} +``` + --- ## Admin Actions diff --git a/web/src/server.ts b/web/src/server.ts index 986d681..883cfbf 100644 --- a/web/src/server.ts +++ b/web/src/server.ts @@ -149,17 +149,24 @@ export async function createWebServer(config: WebServerConfig = {}): Promise`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) { + 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 } + ); + } + } + // No frontend - return 404 for unknown routes return new Response("Not Found", { status: 404 }); },