feat: implement comprehensive item management system with admin UI, API, and asset handling utilities.
All checks were successful
Deploy to Production / test (push) Successful in 44s

This commit is contained in:
syntaxbullet
2026-02-06 12:19:14 +01:00
parent 109b36ffe2
commit 34958aa220
22 changed files with 3718 additions and 15 deletions

View File

@@ -358,6 +358,385 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
}
}
// =====================================
// Items Management API
// =====================================
// GET /api/items - List all items with filtering
if (url.pathname === "/api/items" && req.method === "GET") {
try {
const { itemsService } = await import("@shared/modules/items/items.service");
const filters = {
search: url.searchParams.get("search") || undefined,
type: url.searchParams.get("type") || undefined,
rarity: url.searchParams.get("rarity") || undefined,
limit: url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 100,
offset: url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0,
};
const result = await itemsService.getAllItems(filters);
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify(result, jsonReplacer), {
headers: { "Content-Type": "application/json" }
});
} catch (error) {
logger.error("web", "Error fetching items", error);
return Response.json(
{ error: "Failed to fetch items", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// POST /api/items - Create new item (JSON or multipart with image)
if (url.pathname === "/api/items" && req.method === "POST") {
try {
const { itemsService } = await import("@shared/modules/items/items.service");
const contentType = req.headers.get("content-type") || "";
let itemData: any;
let imageFile: File | null = null;
if (contentType.includes("multipart/form-data")) {
// Handle multipart form with optional image
const formData = await req.formData();
const jsonData = formData.get("data");
imageFile = formData.get("image") as File | null;
if (typeof jsonData === "string") {
itemData = JSON.parse(jsonData);
} else {
return Response.json({ error: "Missing item data" }, { status: 400 });
}
} else {
// JSON-only request
itemData = await req.json();
}
// Validate required fields
if (!itemData.name || !itemData.type) {
return Response.json(
{ error: "Missing required fields: name and type are required" },
{ status: 400 }
);
}
// Check for duplicate name
if (await itemsService.isNameTaken(itemData.name)) {
return Response.json(
{ error: "An item with this name already exists" },
{ status: 409 }
);
}
// Set placeholder URLs if image will be uploaded
const placeholderUrl = "/assets/items/placeholder.png";
const createData = {
name: itemData.name,
description: itemData.description || null,
rarity: itemData.rarity || "Common",
type: itemData.type,
price: itemData.price ? BigInt(itemData.price) : null,
iconUrl: itemData.iconUrl || placeholderUrl,
imageUrl: itemData.imageUrl || placeholderUrl,
usageData: itemData.usageData || null,
};
// Create the item
const item = await itemsService.createItem(createData);
// If image was provided, save it and update the item
if (imageFile && item) {
const assetsDir = resolve(currentDir, "../../bot/assets/graphics/items");
const fileName = `${item.id}.png`;
const filePath = join(assetsDir, fileName);
// Validate file type (check magic bytes for PNG/JPEG/WebP/GIF)
const buffer = await imageFile.arrayBuffer();
const bytes = new Uint8Array(buffer);
const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46;
if (!isPNG && !isJPEG && !isWebP && !isGIF) {
// Rollback: delete the created item
await itemsService.deleteItem(item.id);
return Response.json(
{ error: "Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed." },
{ status: 400 }
);
}
// Check file size (max 2MB)
if (buffer.byteLength > 2 * 1024 * 1024) {
await itemsService.deleteItem(item.id);
return Response.json(
{ error: "Image file too large. Maximum size is 2MB." },
{ status: 400 }
);
}
// Save the file
await Bun.write(filePath, buffer);
// Update item with actual asset URL
const assetUrl = `/assets/items/${fileName}`;
await itemsService.updateItem(item.id, {
iconUrl: assetUrl,
imageUrl: assetUrl,
});
// Return item with updated URLs
const updatedItem = await itemsService.getItemById(item.id);
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify({ success: true, item: updatedItem }, jsonReplacer), {
status: 201,
headers: { "Content-Type": "application/json" }
});
}
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify({ success: true, item }, jsonReplacer), {
status: 201,
headers: { "Content-Type": "application/json" }
});
} catch (error) {
logger.error("web", "Error creating item", error);
return Response.json(
{ error: "Failed to create item", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// GET /api/items/:id - Get single item
if (url.pathname.match(/^\/api\/items\/\d+$/) && req.method === "GET") {
const id = parseInt(url.pathname.split("/").pop()!);
try {
const { itemsService } = await import("@shared/modules/items/items.service");
const item = await itemsService.getItemById(id);
if (!item) {
return Response.json({ error: "Item not found" }, { status: 404 });
}
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify(item, jsonReplacer), {
headers: { "Content-Type": "application/json" }
});
} catch (error) {
logger.error("web", "Error fetching item", error);
return Response.json(
{ error: "Failed to fetch item", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// PUT /api/items/:id - Update item
if (url.pathname.match(/^\/api\/items\/\d+$/) && req.method === "PUT") {
const id = parseInt(url.pathname.split("/").pop()!);
try {
const { itemsService } = await import("@shared/modules/items/items.service");
const data = await req.json() as Record<string, any>;
// Check if item exists
const existing = await itemsService.getItemById(id);
if (!existing) {
return Response.json({ error: "Item not found" }, { status: 404 });
}
// Check for duplicate name (if name is being changed)
if (data.name && data.name !== existing.name) {
if (await itemsService.isNameTaken(data.name, id)) {
return Response.json(
{ error: "An item with this name already exists" },
{ status: 409 }
);
}
}
// Build update data
const updateData: any = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.rarity !== undefined) updateData.rarity = data.rarity;
if (data.type !== undefined) updateData.type = data.type;
if (data.price !== undefined) updateData.price = data.price ? BigInt(data.price) : null;
if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl;
if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl;
if (data.usageData !== undefined) updateData.usageData = data.usageData;
const updatedItem = await itemsService.updateItem(id, updateData);
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify({ success: true, item: updatedItem }, jsonReplacer), {
headers: { "Content-Type": "application/json" }
});
} catch (error) {
logger.error("web", "Error updating item", error);
return Response.json(
{ error: "Failed to update item", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// DELETE /api/items/:id - Delete item
if (url.pathname.match(/^\/api\/items\/\d+$/) && req.method === "DELETE") {
const id = parseInt(url.pathname.split("/").pop()!);
try {
const { itemsService } = await import("@shared/modules/items/items.service");
const existing = await itemsService.getItemById(id);
if (!existing) {
return Response.json({ error: "Item not found" }, { status: 404 });
}
// Delete the item
await itemsService.deleteItem(id);
// Try to delete associated asset file
const assetsDir = resolve(currentDir, "../../bot/assets/graphics/items");
const assetPath = join(assetsDir, `${id}.png`);
try {
const assetFile = Bun.file(assetPath);
if (await assetFile.exists()) {
await Bun.write(assetPath, ""); // Clear file
// Note: Bun doesn't have a direct delete, but we can use unlink via node:fs
const { unlink } = await import("node:fs/promises");
await unlink(assetPath);
}
} catch (e) {
// Non-critical: log but don't fail
logger.warn("web", `Could not delete asset file for item ${id}`, e);
}
return new Response(null, { status: 204 });
} catch (error) {
logger.error("web", "Error deleting item", error);
return Response.json(
{ error: "Failed to delete item", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// POST /api/items/:id/icon - Upload/update item icon
if (url.pathname.match(/^\/api\/items\/\d+\/icon$/) && req.method === "POST") {
const id = parseInt(url.pathname.split("/")[3] || "0");
try {
const { itemsService } = await import("@shared/modules/items/items.service");
// Check if item exists
const existing = await itemsService.getItemById(id);
if (!existing) {
return Response.json({ error: "Item not found" }, { status: 404 });
}
// Parse multipart form
const formData = await req.formData();
const imageFile = formData.get("image") as File | null;
if (!imageFile) {
return Response.json({ error: "No image file provided" }, { status: 400 });
}
// Validate file type
const buffer = await imageFile.arrayBuffer();
const bytes = new Uint8Array(buffer);
const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46;
if (!isPNG && !isJPEG && !isWebP && !isGIF) {
return Response.json(
{ error: "Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed." },
{ status: 400 }
);
}
// Check file size (max 2MB)
if (buffer.byteLength > 2 * 1024 * 1024) {
return Response.json(
{ error: "Image file too large. Maximum size is 2MB." },
{ status: 400 }
);
}
// Save the file
const assetsDir = resolve(currentDir, "../../bot/assets/graphics/items");
const fileName = `${id}.png`;
const filePath = join(assetsDir, fileName);
await Bun.write(filePath, buffer);
// Update item with new icon URL
const assetUrl = `/assets/items/${fileName}`;
const updatedItem = await itemsService.updateItem(id, {
iconUrl: assetUrl,
imageUrl: assetUrl,
});
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify({ success: true, item: updatedItem }, jsonReplacer), {
headers: { "Content-Type": "application/json" }
});
} catch (error) {
logger.error("web", "Error uploading item icon", error);
return Response.json(
{ error: "Failed to upload icon", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// =====================================
// Static Asset Serving (/assets/*)
// =====================================
if (url.pathname.startsWith("/assets/")) {
const assetsRoot = resolve(currentDir, "../../bot/assets/graphics");
const assetPath = url.pathname.replace("/assets/", "");
// Security: prevent path traversal
const safePath = join(assetsRoot, assetPath);
if (!safePath.startsWith(assetsRoot)) {
return new Response("Forbidden", { status: 403 });
}
const file = Bun.file(safePath);
if (await file.exists()) {
// Determine MIME type based on extension
const ext = safePath.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"webp": "image/webp",
"gif": "image/gif",
};
const contentType = mimeTypes[ext || ""] || "application/octet-stream";
return new Response(file, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
}
});
}
return new Response("Not found", { status: 404 });
}
// Static File Serving
let pathName = url.pathname;
if (pathName === "/") pathName = "/index.html";