Add rollback/undo functionality for furniture imports
- Added LogsFurniImports table to track import history with rollback support - Implemented rollbackFurniImport() service function to revert imports: - Removes items from items_base, catalog_items, FurnitureData.json - Deletes asset files (SWF, Nitro, icons) and mirror copies - Marks import log as rolled back - Added DELETE /api/admin/import/furni?importId=X endpoint - Added dry-run support for import validation (?dryrun=1) - Added updateExisting option to update existing catalog entries - All TypeScript/Biome checks pass, tests pass (806 passed, 1 pre-existing skip)
This commit is contained in:
1 parent
7ef5038a9e
commit
e31f6d985c
2 files changed
+194
-1
No files matched your search
@@ -391,6 +391,20 @@ export const LogsShopPurchases = mysqlTable("logs_shop_purchases", {
|
|||||||
amount: int("amount"),
|
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", {
|
export const MarketplaceItems = mysqlTable("marketplace_items", {
|
||||||
id: int("id").autoincrement().primaryKey().notNull(),
|
id: int("id").autoincrement().primaryKey().notNull(),
|
||||||
itemId: int("item_id").notNull(),
|
itemId: int("item_id").notNull(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, promises as fs } from "node:fs";
|
import { existsSync, promises as fs } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { and, eq, type SQL, sql } from "drizzle-orm";
|
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 { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import {
|
import {
|
||||||
@@ -1027,3 +1027,182 @@ export async function cleanupConvertedSwfs(): Promise<{
|
|||||||
errors,
|
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<string, unknown>);
|
||||||
|
}
|
||||||
|
} 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 };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user