import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { logAudit } from "@/lib/services/audit"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { autoPriceFurni, CATEGORY_PAGE, classifyFurni, cleanupConvertedSwfs, deleteImportedItem, downloadFile, ensureDirectories, IMPORTED_PAGE_CAPTION_SAVE, importSingleFurni, } from "@/lib/services/furni-import"; import { getHabboItFurnidata } from "@/lib/services/habbo-furnidata-cache"; import { listFurniture } from "@/lib/services/habbofurni"; import { rcon } from "@/lib/services/rcon"; import { convertSwfToNitro } from "@/lib/services/swf-to-nitro"; // Re-export for batch route backward compatibility export { ensureDirectories, importSingleFurni, } from "@/lib/services/furni-import"; export type { ImportSingleResult } from "@/types/furni"; // ── Delete imported item or clean up SWF files ────────────────────── export const DELETE = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => { const classname = request.nextUrl.searchParams.get("classname"); // ── Delete specific imported item ───────────────────────────────── if (classname) { try { const { spriteId, deletedFiles, errors } = await deleteImportedItem(classname); // Refresh game server caches try { await rcon.updateCatalog(); await rcon.updateItems(); } catch (err) { errors.push(`RCON: ${(err as Error).message}`); } logAudit({ userId: ctx.session.user.id, action: "furni_delete", target: "ItemsBase", targetId: spriteId, after: { classname, deletedFiles, errors: errors.length > 0 ? errors : undefined, }, }); return apiOk({ classname, deletedFiles, errors: errors.length > 0 ? errors : undefined, }); } catch (err) { return apiError((err as Error).message, 404); } } // ── Clean up SWF files that have been converted to Nitro ────────── const result = await cleanupConvertedSwfs(); if (result.total === 0 && result.deleted === 0) { return apiOk({ deleted: 0, message: "SWF directory does not exist" }); } return apiOk({ deleted: result.deleted, total: result.total, remaining: result.remaining, errors: result.errors.length > 0 ? result.errors : undefined, }); }, ); // ── Search furni via official Habbo furnidata ─────────────────────── export const GET = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request) => { const { nitroDir } = await getFurniAssetDirs(); // Stats endpoint const action = request.nextUrl.searchParams.get("action"); if (action === "stats") { const [totalResult, catalogResult, nitroMissingResult] = await Promise.all([ prisma.$queryRaw< [{ cnt: bigint }] >`SELECT COUNT(*) as cnt FROM items_base`, prisma.$queryRaw< [{ cnt: bigint }] >`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`, prisma.$queryRaw< [{ cnt: bigint }] >`SELECT COUNT(*) as cnt FROM items_base WHERE item_name NOT IN (SELECT catalog_name FROM catalog_items)`, ]); const totalInDb = Number(totalResult[0].cnt); const inCatalog = Number(catalogResult[0].cnt); const notInCatalog = Number(nitroMissingResult[0].cnt); // Count missing .nitro files by checking filesystem const importedItems = await prisma.$queryRaw< Array<{ item_name: string }> >` SELECT ib.item_name FROM items_base ib INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name LIMIT 10000 `; let missingNitro = 0; for (const item of importedItems) { const starIdx = item.item_name.indexOf("*"); const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name; const nitroPath = path.join( /*turbopackIgnore: true*/ nitroDir, `${base}.nitro`, ); if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) missingNitro++; } return apiOk({ totalInDb, inCatalog, notInCatalog, missingNitro }); } // Missing nitro endpoint: returns only imported items missing .nitro files if (action === "missing-nitro") { const pageNum = parseInt( request.nextUrl.searchParams.get("page") || "1", 10, ); const perPage = 50; const importedItems = await prisma.$queryRaw< Array<{ item_name: string; public_name: string; sprite_id: number; type: string; }> >` SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name `; // Filter to only those missing .nitro on filesystem const missing = importedItems.filter((item) => { const starIdx = item.item_name.indexOf("*"); const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name; const p = path.join( /*turbopackIgnore: true*/ nitroDir, `${base}.nitro`, ); return !existsSync(/*turbopackIgnore: true*/ p); }); const total = missing.length; const lastPage = Math.max(Math.ceil(total / perPage), 1); const start = (pageNum - 1) * perPage; const pageItems = missing.slice(start, start + perPage); // Enrich with revision from official furnidata const furnidata = await getHabboItFurnidata(); const items = pageItems.map((item) => { const fd = furnidata.get(item.item_name) || furnidata.get(item.item_name.split("*")[0]) || furnidata.get(`${item.item_name}*0`); return { id: item.sprite_id, classname: item.item_name, name: item.public_name || item.item_name, description: fd?.description || "", type: item.type === "i" ? "wallitem" : "flooritem", revision: fd?.revision || 0, category: fd?.category || fd?.furniline || "", alreadyImported: true, nitroExists: false, }; }); return apiOk({ items, meta: { currentPage: pageNum, lastPage, total, perPage }, }); } const search = request.nextUrl.searchParams.get("search") || ""; const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10); // Pull every imported classname once so we can both (a) sort non-imported // items to the top *before* pagination and (b) skip the per-page lookup // below. Single-column indexed query — fast even at 10k+ rows. const allImported = await prisma.itemsBase.findMany({ select: { itemName: true }, }); const importedSet = new Set( allImported.map((e) => e.itemName).filter((n): n is string => !!n), ); const result = await listFurniture({ search: search || undefined, page, perPage: 50, importedSet, }); const enriched = result.items.map((item) => { const imported = importedSet.has(item.classname); let nitroExists = false; if (imported) { const starIdx = item.classname.indexOf("*"); const base = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname; const p = path.join( /*turbopackIgnore: true*/ nitroDir, `${base}.nitro`, ); nitroExists = existsSync(/*turbopackIgnore: true*/ p); } return { ...item, alreadyImported: imported, nitroExists }; }); return apiOk({ items: enriched, meta: result.meta }); }, ); // ── Import furni (single or batch) ────────────────────────────────── export const POST = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => { const body = await request.json(); await ensureDirectories(); // ── Batch import ───────────────────────────────────────────────── if (Array.isArray(body.items)) { const results: Array<{ classname: string; ok: boolean; itemId?: number; catalogItemId?: number | null; dimensions?: { x: number; y: number; z: number }; spriteCount?: number; warnings: string[]; error?: string; }> = []; for (const item of body.items) { if (!item.classname || !item.name) { results.push({ classname: item.classname || "unknown", ok: false, warnings: [], error: "Missing classname or name", }); continue; } try { const result = await importSingleFurni({ id: item.id ?? 0, classname: item.classname, name: item.name, description: item.description ?? "", type: item.type ?? "flooritem", revision: item.revision ?? 0, category: item.category ?? "unknown", }); results.push({ classname: item.classname, ...result }); if (result.ok) { logAudit({ userId: ctx.session.user.id, action: "furni_import", target: "ItemsBase", targetId: result.itemId!, after: { classname: item.classname, name: item.name, type: item.type === "wallitem" ? "i" : "s", catalogItemId: result.catalogItemId, dimensions: result.dimensions, batch: true, warnings: result.warnings.length > 0 ? result.warnings : undefined, }, }); } } catch (err) { results.push({ classname: item.classname, ok: false, warnings: [], error: (err as Error).message, }); } } // Refresh caches once after batch try { await rcon.updateCatalog(); await rcon.updateItems(); } catch (err) { console.warn( "[import-furni] RCON update failed after batch:", (err as Error).message, ); } const succeeded = results.filter((r) => r.ok).length; const failed = results.filter((r) => !r.ok).length; return apiOk({ batch: true, succeeded, failed, results }); } // ── Single import ──────────────────────────────────────────────── const { id, classname, name, description, type, revision, category } = body; if (!classname || !name) return apiError("Missing classname or name", 400); const result = await importSingleFurni({ id: id ?? 0, classname, name, description: description ?? "", type: type ?? "flooritem", revision: revision ?? 0, category: category ?? "unknown", }); if (!result.ok) return apiError(result.error!, 409); // Refresh game server caches try { await rcon.updateCatalog(); await rcon.updateItems(); } catch (err) { console.warn( "[import-furni] RCON update failed:", (err as Error).message, ); result.warnings.push("RCON cache refresh failed"); } logAudit({ userId: ctx.session.user.id, action: "furni_import", target: "ItemsBase", targetId: result.itemId!, after: { classname, name, spriteId: id ?? 0, type: type === "wallitem" ? "i" : "s", catalogItemId: result.catalogItemId, dimensions: result.dimensions, spriteCount: result.spriteCount, warnings: result.warnings.length > 0 ? result.warnings : undefined, }, }); return apiOk({ id: result.itemId, catalogItemId: result.catalogItemId, dimensions: result.dimensions, spriteCount: result.spriteCount, warnings: result.warnings.length > 0 ? result.warnings : undefined, }); }, ); // ── Re-generate missing .nitro files (PATCH) ───────────────────────── export const PATCH = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request) => { const body = await request.json(); const items: Array<{ classname: string; revision: number }> = body.items || []; if (items.length === 0) return apiError("No items provided", 400); await ensureDirectories(); const { swfDir, nitroDir } = await getFurniAssetDirs(); const results: Array<{ classname: string; ok: boolean; warning?: string }> = []; for (const item of items) { const starIdx = item.classname.indexOf("*"); const baseClassname = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname; const nitroPath = path.join( /*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`, ); // Skip if .nitro already exists if (existsSync(/*turbopackIgnore: true*/ nitroPath)) { results.push({ classname: item.classname, ok: true, warning: "Already exists", }); continue; } // Resolve revision: try furnidata first, then spriteId as fallback let rev = item.revision || 0; if (rev === 0) { const furnidata = await getHabboItFurnidata(); const fd = furnidata.get(item.classname) || furnidata.get(baseClassname) || furnidata.get(`${baseClassname}*0`); if (fd) rev = fd.revision; } const swfPath = path.join(swfDir, `${baseClassname}.swf`); // Download SWF if not present — try multiple revision candidates if (!existsSync(swfPath)) { // Build list of revisions to try: known rev, spriteId, common fallbacks const spriteId = await prisma.itemsBase.findFirst({ where: { itemName: item.classname }, select: { spriteId: true }, }); const revsToTry = [ ...new Set([rev, spriteId?.spriteId || 0].filter((r) => r > 0)), ]; let downloaded = false; for (const tryRev of revsToTry) { const dl = await downloadFile( `https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`, swfPath, { validate: "swf" }, ); if (dl.ok) { rev = tryRev; downloaded = true; break; } } if (!downloaded) { results.push({ classname: item.classname, ok: false, warning: `SWF not found on Habbo CDN (tried revisions: ${revsToTry.join(", ") || "none"})`, }); continue; } } // Convert SWF → Nitro + update FurnitureData.json try { const swfBuffer = await fs.readFile(swfPath); const conversion = convertSwfToNitro(swfBuffer, baseClassname); await fs.writeFile(nitroPath, conversion.bundle); // Add to FurnitureData.json if not already present try { const { buildFurniEntry, appendFurniEntry, readFurniData } = await import("@/lib/services/furni-data"); const furniData = (await readFurniData()) as { roomitemtypes?: { furnitype?: Array<{ classname?: string }> }; wallitemtypes?: { furnitype?: Array<{ classname?: string }> }; }; // Check if already in FurnitureData const allEntries = [ ...(furniData.roomitemtypes?.furnitype || []), ...(furniData.wallitemtypes?.furnitype || []), ]; const alreadyInFD = allEntries.some( (e) => e.classname === baseClassname || e.classname === item.classname, ); if (!alreadyInFD) { const dbItem = await prisma.itemsBase.findFirst({ where: { itemName: item.classname }, select: { id: true, spriteId: true, publicName: true, type: true, }, }); const furnidata = await getHabboItFurnidata(); const fd = furnidata.get(item.classname) || furnidata.get(baseClassname); const itemType = dbItem?.type === "i" ? "i" : "s"; const entry = buildFurniEntry({ id: dbItem?.spriteId || 0, classname: baseClassname, revision: rev, category: fd?.category || "", name: dbItem?.publicName || baseClassname, description: fd?.description || "", spriteId: dbItem?.spriteId || 0, dims: conversion.dimensions || { x: 1, y: 1, z: 1 }, metadata: conversion.metadata || null, habboIt: fd || null, }); await appendFurniEntry(entry, itemType); } } catch (fdErr) { console.warn( `[regen] FurnitureData update skipped for ${item.classname}:`, (fdErr as Error).message, ); } results.push({ classname: item.classname, ok: true }); } catch (err) { results.push({ classname: item.classname, ok: false, warning: (err as Error).message, }); } } const succeeded = results.filter((r) => r.ok).length; const failed = results.filter((r) => !r.ok).length; return apiOk({ succeeded, failed, results }); }, ); // ── Re-classify ALL imported items across all sub-pages + update prices (PUT) ── // Supports ?dryrun=1 to preview changes without applying them. export const PUT = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => { const isDryRun = request.nextUrl.searchParams.get("dryrun") === "1"; // 1. Find the root "imported_furni" page const parentPage = await prisma.catalogPages.findFirst({ where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE }, select: { id: true }, }); if (!parentPage) { return apiOk({ dryRun: isDryRun, moved: 0, repriced: 0, message: "No imported_furni page found — nothing to reorganize", }); } // 2. Collect ALL imported page IDs (parent + every imp_* sub-page) const subPages = await prisma.catalogPages.findMany({ where: { parentId: parentPage.id }, select: { id: true, captionSave: true }, }); const allImportedPageIds = [ String(parentPage.id), ...subPages.map((p) => String(p.id)), ]; // 3. Find ALL catalog_items across all imported pages // page_id is VARCHAR in DB so we use string placeholders const inPlaceholders = allImportedPageIds.map(() => "?").join(","); const allItems = await prisma.$queryRawUnsafe< Array<{ id: number; item_ids: string; catalog_name: string; page_id: string; cost_credits: number; }> >( `SELECT id, item_ids, catalog_name, page_id, cost_credits FROM catalog_items WHERE page_id IN (${inPlaceholders})`, ...allImportedPageIds, ); if (allItems.length === 0) { return apiOk({ dryRun: isDryRun, moved: 0, repriced: 0, message: "No imported items found — nothing to reorganize", }); } // 4. Build classname → type map from items_base const uniqueItemIds = [ ...new Set(allItems.map((i) => Number(i.item_ids))), ].filter((n) => !Number.isNaN(n)); const dbItems = uniqueItemIds.length > 0 ? await prisma.itemsBase.findMany({ where: { id: { in: uniqueItemIds } }, select: { id: true, itemName: true, type: true }, }) : []; const typeMap = new Map(); for (const di of dbItems) { if (di.itemName) typeMap.set(di.itemName, di.type || "s"); } // 5. Pre-load category page map (captionSave → pageId) to avoid N+1 queries const categoryPageMap = new Map(); for (const sp of subPages) { if (sp.captionSave) categoryPageMap.set(sp.captionSave, sp.id); } // Helper: resolve or create category page using the cached map async function resolvePageId(catKey: string): Promise { const captionSave = `imp_${catKey}`; const cached = categoryPageMap.get(captionSave); if (cached !== undefined) return String(cached); if (isDryRun) return "new"; // Create the page (only happens once per new category) const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other; const parentId = parentPage?.id; await prisma.$executeRaw` INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser) VALUES (${captionSave}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '') `; const created = await prisma.catalogPages.findFirst({ where: { captionSave, parentId }, select: { id: true }, }); categoryPageMap.set(captionSave, created?.id); return String(created?.id); } // 6. Classify all items (pure computation + cached lookups, no per-item DB queries) let moved = 0; let repriced = 0; let skipped = 0; const errors: string[] = []; const categoryCounts: Record = {}; const pagesWithItems = new Set(); // Collect batched updates: key = "pageId|credits|points|pointsType" → item ids const updateBatches = new Map(); // First pass: resolve any new category pages (max ~14 DB inserts) const neededCategories = new Set(); for (const item of allItems) { const itemType = typeMap.get(item.catalog_name) || "s"; neededCategories.add(classifyFurni(item.catalog_name, itemType)); } for (const catKey of neededCategories) { try { await resolvePageId(catKey); } catch (err) { errors.push(`create page ${catKey}: ${(err as Error).message}`); } } // Second pass: classify items (no DB calls) for (const item of allItems) { const itemType = typeMap.get(item.catalog_name) || "s"; const catKey = classifyFurni(item.catalog_name, itemType); categoryCounts[catKey] = (categoryCounts[catKey] || 0) + 1; const captionSave = `imp_${catKey}`; const cachedPageId = categoryPageMap.get(captionSave); const correctPageIdStr = cachedPageId !== undefined ? String(cachedPageId) : "new"; const price = autoPriceFurni(item.catalog_name); const needsMove = item.page_id !== correctPageIdStr; const needsReprice = item.cost_credits !== price.credits; pagesWithItems.add(correctPageIdStr); if (!needsMove && !needsReprice) { skipped++; continue; } if (needsMove) moved++; if (needsReprice) repriced++; if (!isDryRun) { const batchKey = `${correctPageIdStr}|${price.credits}|${price.points}|${price.pointsType}`; const batch = updateBatches.get(batchKey); if (batch) { batch.push(item.id); } else { updateBatches.set(batchKey, [item.id]); } } } // 7. Apply batched updates (one UPDATE per category/price combo instead of per item) if (!isDryRun) { for (const [batchKey, ids] of updateBatches) { const [pageId, credits, points, pointsType] = batchKey.split("|"); try { const idPlaceholders = ids.map(() => "?").join(","); await prisma.$queryRawUnsafe( `UPDATE catalog_items SET page_id = ?, cost_credits = ?, cost_points = ?, points_type = ? WHERE id IN (${idPlaceholders})`, pageId, Number(credits), Number(points), Number(pointsType), ...ids, ); } catch (err) { errors.push(`batch update: ${(err as Error).message}`); } } } // 8. Cleanup empty sub-pages (only if NOT dry-run) let pagesDeleted = 0; if (!isDryRun) { for (const subPage of subPages) { const subPageIdStr = String(subPage.id); if (!pagesWithItems.has(subPageIdStr)) { try { const remaining = await prisma.$queryRawUnsafe< Array<{ cnt: bigint }> >( `SELECT COUNT(*) as cnt FROM catalog_items WHERE page_id = ?`, subPageIdStr, ); const count = Number(remaining[0]?.cnt ?? 0); if (count === 0) { await prisma.$executeRaw`DELETE FROM catalog_pages WHERE id = ${subPage.id}`; pagesDeleted++; } } catch (err) { errors.push( `cleanup ${subPage.captionSave}: ${(err as Error).message}`, ); } } } } // 9. Refresh game server caches (only if NOT dry-run and changes were made) if (!isDryRun && (moved > 0 || repriced > 0 || pagesDeleted > 0)) { try { await rcon.updateCatalog(); } catch (err) { errors.push(`RCON: ${(err as Error).message}`); } } if (!isDryRun) { logAudit({ userId: ctx.session.user.id, action: "furni_reorganize", target: "CatalogItems", targetId: parentPage.id, after: { moved, repriced, skipped, pagesDeleted, categoryCounts, errors: errors.length > 0 ? errors : undefined, }, }); } return apiOk({ dryRun: isDryRun, moved, repriced, skipped, pagesDeleted, total: allItems.length, categoryCounts, errors: errors.length > 0 ? errors : undefined, }); }, );