import { existsSync } from "node:fs"; import { asc, inArray, sql } from "drizzle-orm"; import { apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; import { db, ItemsBase, queryRows } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { appendFurniEntriesBatch, buildFurniEntry, getFurnitureDataPath, readFurniData, } from "@/lib/services/furni-data"; import { lookupOfficialHabboFurni } 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 db .select({ id: ItemsBase.id, spriteId: ItemsBase.spriteId, itemName: ItemsBase.itemName, publicName: ItemsBase.publicName, type: ItemsBase.type, width: ItemsBase.width, length: ItemsBase.length, stackHeight: ItemsBase.stackHeight, }) .from(ItemsBase) .where(inArray(ItemsBase.id, slice)); const entries: Array<{ entry: Record; itemType: string; }> = []; for (const row of rows) { try { const classname = row.itemName; let officialHabbo = null; try { officialHabbo = await lookupOfficialHabboFurni(classname); } catch { /* optional */ } const entry = buildFurniEntry({ id: row.id, classname, revision: officialHabbo?.revision ?? 0, category: officialHabbo?.category || "unknown", name: row.publicName || officialHabbo?.name || classname, description: officialHabbo?.description || "", spriteId: row.spriteId, dims: { x: row.width, y: row.length, z: Math.round(Number(row.stackHeight)), }, metadata: null, officialHabbo, }); 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 db .select({ id: ItemsBase.id }) .from(ItemsBase) .orderBy(asc(ItemsBase.id)); return rows.map((r) => r.id); } if (mode === "missing" || mode === "broken") { const furniDataPath = await getFurnitureDataPath(); if (!existsSync(furniDataPath)) { const rows = await db .select({ id: ItemsBase.id }) .from(ItemsBase) .orderBy(asc(ItemsBase.id)); 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 db .select({ id: ItemsBase.id, itemName: ItemsBase.itemName, spriteId: ItemsBase.spriteId, width: ItemsBase.width, length: ItemsBase.length, }) .from(ItemsBase) .orderBy(asc(ItemsBase.id)); 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 queryRows<{ target_id: number }>(sql` 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); }