import { existsSync } from "node:fs"; import { withAdmin } from "@/lib/api-handler"; import { apiOk } from "@/lib/api-response"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { appendFurniEntriesBatch, buildFurniEntry, getFurnitureDataPath, readFurniData, } from "@/lib/services/furni-data"; import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache"; import { rcon } from "@/lib/services/rcon"; /** * POST /api/admin/import/furni/resync * * Modes (query params, pick one): * ?days=N — items imported in the last N days (from admin_audit_log). * ?missing=1 — every items_base row whose classname is absent from * FurnitureData.json (authoritative for "what's broken"). * ?all=1 — every items_base row (rebuilds entries for all imports). * * Default: ?days=7 * * Entries are upserted by classname so running this repeatedly is safe. * Triggers RCON updateCatalog + updateItems at the end. */ export const POST = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request) => { const qp = request.nextUrl.searchParams; const mode: "days" | "missing" | "broken" | "all" = qp.get("all") === "1" ? "all" : qp.get("broken") === "1" ? "broken" : qp.get("missing") === "1" ? "missing" : "days"; const days = Math.max( 1, Math.min(parseInt(qp.get("days") || "7", 10) || 7, 365), ); const targetIds = await resolveTargetIds(mode, days); if (targetIds.length === 0) { return apiOk({ mode, days: mode === "days" ? days : undefined, examined: 0, resynced: 0, failed: 0, errors: [], }); } // Process in chunks to keep memory flat and make progress visible. const CHUNK = 500; let resynced = 0; const errors: Array<{ classname: string; message: string }> = []; for (let i = 0; i < targetIds.length; i += CHUNK) { const slice = targetIds.slice(i, i + CHUNK); const rows = await prisma.itemsBase.findMany({ where: { id: { in: slice } }, select: { id: true, spriteId: true, itemName: true, publicName: true, type: true, width: true, length: true, stackHeight: true, }, }); const entries: Array<{ entry: Record; itemType: string; }> = []; for (const row of rows) { try { const classname = row.itemName; let habboIt = null; try { habboIt = await lookupHabboIt(classname); } catch { /* optional */ } const entry = buildFurniEntry({ id: row.id, classname, revision: habboIt?.revision ?? 0, category: habboIt?.category || "unknown", name: row.publicName || habboIt?.name || classname, description: habboIt?.description || "", spriteId: row.spriteId, dims: { x: row.width, y: row.length, z: Math.round(Number(row.stackHeight)), }, metadata: null, habboIt, }); entries.push({ entry, itemType: row.type === "i" ? "i" : "s" }); } catch (err) { errors.push({ classname: row.itemName, message: (err as Error).message, }); } } if (entries.length > 0) { try { await appendFurniEntriesBatch(entries); resynced += entries.length; } catch (err) { errors.push({ classname: `_chunk_${i}_${i + slice.length}_`, message: `FurnitureData.json write failed: ${(err as Error).message}`, }); break; } } } let rconOk = false; try { const a = await rcon.updateCatalog(); const b = await rcon.updateItems(); rconOk = a && b; } catch { /* logged in rcon service */ } return apiOk({ mode, days: mode === "days" ? days : undefined, examined: targetIds.length, resynced, failed: errors.length, rconOk, errors: errors.slice(0, 50), }); }, ); async function resolveTargetIds( mode: "days" | "missing" | "broken" | "all", days: number, ): Promise { if (mode === "all") { const rows = await prisma.itemsBase.findMany({ select: { id: true }, orderBy: { id: "asc" }, }); return rows.map((r) => r.id); } if (mode === "missing" || mode === "broken") { const furniDataPath = await getFurnitureDataPath(); if (!existsSync(furniDataPath)) { const rows = await prisma.itemsBase.findMany({ select: { id: true }, orderBy: { id: "asc" }, }); return rows.map((r) => r.id); } const furniData = (await readFurniData()) as { roomitemtypes?: { furnitype: Array> }; wallitemtypes?: { furnitype: Array> }; }; const byClassname = new Map>(); for (const section of [furniData.roomitemtypes, furniData.wallitemtypes]) { for (const e of section?.furnitype ?? []) { if (typeof e?.classname === "string") byClassname.set(e.classname, e); } } const rows = await prisma.itemsBase.findMany({ select: { id: true, itemName: true, spriteId: true, width: true, length: true, }, orderBy: { id: "asc" }, }); if (mode === "missing") { return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id); } // broken: entry present but inconsistent with items_base return rows .filter((r) => { const e = byClassname.get(r.itemName); if (!e) return true; const entryId = Number(e.id ?? -1); if (entryId !== r.id && entryId !== r.spriteId) return true; const xdim = Number(e.xdim ?? 0); const ydim = Number(e.ydim ?? 0); if (xdim <= 0 || ydim <= 0) return true; if (xdim !== r.width || ydim !== r.length) return true; return false; }) .map((r) => r.id); } // mode === 'days' const sinceIso = new Date( Date.now() - days * 24 * 60 * 60 * 1000, ).toISOString(); const sinceMysql = sinceIso.slice(0, 19).replace("T", " "); const auditRows = await prisma.$queryRaw>` SELECT DISTINCT target_id FROM admin_audit_log WHERE action = 'furni_import' AND target = 'ItemsBase' AND target_id IS NOT NULL AND created_at >= ${sinceMysql} ORDER BY target_id ASC `; return auditRows.map((r) => r.target_id).filter(Boolean); }