diff --git a/src/db/schema.ts b/src/db/schema.ts index 084f0d1266..4f653fb5f4 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -391,6 +391,20 @@ export const LogsShopPurchases = mysqlTable("logs_shop_purchases", { amount: int("amount"), }); +export const LogsFurniImports = mysqlTable("logs_furni_imports", { + id: int("id", { unsigned: true }).autoincrement().primaryKey().notNull(), + timestamp: int("timestamp", { unsigned: true }).notNull(), + userId: int("user_id").notNull(), + batch: tinyint("batch").notNull().default(0), + totalItems: int("total_items").notNull().default(0), + succeeded: int("succeeded").notNull().default(0), + failed: int("failed").notNull().default(0), + details: text("details").notNull(), + rolledBack: tinyint("rolled_back").notNull().default(0), + rolledBackAt: int("rolled_back_at", { unsigned: true }), + rolledBackBy: int("rolled_back_by"), +}); + export const MarketplaceItems = mysqlTable("marketplace_items", { id: int("id").autoincrement().primaryKey().notNull(), itemId: int("item_id").notNull(), diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index db65453150..c016699123 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -1,7 +1,7 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { and, eq, type SQL, sql } from "drizzle-orm"; -import { CatalogPages, db, ItemsBase } from "@/lib/db"; +import { CatalogPages, db, ItemsBase, LogsFurniImports } from "@/lib/db"; import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel"; import { logger } from "@/lib/logger"; import { @@ -1027,3 +1027,182 @@ export async function cleanupConvertedSwfs(): Promise<{ errors, }; } + +/** Rollback a furni import by import log ID. Returns { ok, rolledBack: number, errors: string[] }. */ +export async function rollbackFurniImport( + importId: number, + rolledBackBy: number, +): Promise<{ + ok: boolean; + rolledBack: number; + errors: string[]; +}> { + const errors: string[] = []; + let rolledBack = 0; + + // Get the import log + const [importLog] = await db + .select() + .from(LogsFurniImports) + .where(eq(LogsFurniImports.id, importId)) + .limit(1); + + if (!importLog) { + return { + ok: false, + rolledBack: 0, + errors: [`Import log ${importId} not found`], + }; + } + + if (importLog.rolledBack) { + return { + ok: false, + rolledBack: 0, + errors: [ + `Import ${importId} already rolled back at ${importLog.rolledBackAt}`, + ], + }; + } + + let details: { + items?: Array<{ + classname: string; + spriteId: number; + baseClassname?: string; + iconFileName?: string; + }>; + } = {}; + try { + details = JSON.parse(importLog.details); + } catch { + return { + ok: false, + rolledBack: 0, + errors: ["Invalid import details JSON"], + }; + } + + if (!details.items || !Array.isArray(details.items)) { + return { ok: false, rolledBack: 0, errors: ["No items to rollback"] }; + } + + const assetTargets = await getFurniAssetWriteTargets(); + const { swfDir, iconDir, nitroDir } = assetTargets; + + for (const item of details.items) { + const { classname, spriteId: _spriteId, baseClassname } = item; + const base = baseClassname || classname.split("*")[0] || classname; + + const errorsForItem: string[] = []; + + // 1. Delete from catalog_items + try { + await db.execute( + sql`DELETE FROM catalog_items WHERE item_ids = ${String(item.spriteId)} AND catalog_name = ${item.classname}`, + ); + } catch (err) { + errorsForItem.push(`catalog_items: ${(err as Error).message}`); + } + + // 2. Delete from items_base + try { + await db.execute( + sql`DELETE FROM items_base WHERE id = ${item.spriteId} AND item_name = ${item.classname}`, + ); + } catch (err) { + errorsForItem.push(`items_base: ${(err as Error).message}`); + } + + // 3. Remove from FurnitureData.json + try { + const { readFurniData, writeFurniData } = await import( + "@/lib/services/furni-data" + ); + const furniData = (await readFurniData()) as { + roomitemtypes?: { furnitype?: Array<{ classname?: string }> }; + wallitemtypes?: { furnitype?: Array<{ classname?: string }> }; + }; + + let changed = false; + for (const section of ["roomitemtypes", "wallitemtypes"] as const) { + const sec = furniData[section] as + | { furnitype: Array<{ classname?: string }> } + | undefined; + if (!sec) continue; + const furnitype = sec.furnitype ?? []; + const filtered = furnitype.filter( + (e) => + typeof e.classname !== "string" || e.classname !== item.classname, + ); + if (filtered.length !== furnitype.length) { + ( + furniData[section] as { furnitype: Array<{ classname?: string }> } + ).furnitype = filtered; + changed = true; + } + } + if (changed) { + await writeFurniData(furniData as Record); + } + } catch (err) { + errorsForItem.push(`FurnitureData.json: ${(err as Error).message}`); + } + + // 4. Delete asset files + const filesToDelete = [ + path.join(nitroDir, `${base}.nitro`), + path.join(swfDir, `${base}.swf`), + path.join(iconDir, `${classname.replace(/\*/g, "_")}_icon.png`), + ]; + + for (const filePath of filesToDelete) { + if (existsSync(filePath)) { + try { + await fs.unlink(filePath); + } catch (err) { + errorsForItem.push( + `delete ${path.basename(filePath)}: ${(err as Error).message}`, + ); + } + } + } + + // Mirror directories + for (const dirs of assetTargets.mirrorDirs) { + for (const filePath of [ + path.join(dirs.nitroDir, `${classname.split("*")[0]}.nitro`), + path.join(dirs.swfDir, `${classname.split("*")[0]}.swf`), + path.join(dirs.iconDir, `${classname.replace(/\*/g, "_")}_icon.png`), + ]) { + if (existsSync(filePath)) { + try { + await fs.unlink(filePath); + } catch { + /* ignore */ + } + } + } + } + + if (errorsForItem.length === 0) { + rolledBack++; + } else { + errors.push(`${item.classname}: ${errorsForItem.join(", ")}`); + } + } + + // Mark as rolled back + if (rolledBack > 0) { + await db + .update(LogsFurniImports) + .set({ + rolledBack: 1, + rolledBackAt: Math.floor(Date.now() / 1000), + rolledBackBy, + }) + .where(eq(LogsFurniImports.id, importId)); + } + + return { ok: errors.length === 0, rolledBack, errors }; +}