import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { readFurniData, withFurniDataLock, writeFurniData, } from "@/lib/services/furni-data"; import { autoPriceFurni, getOrCreateCategoryPage, } from "@/lib/services/furni-import"; export interface MissingCatalogEntry { classname: string; itemId: number; itemType: string; } export interface CatalogSqlResult { statements: string[]; sql: string; count: number; } function escSqlLiteral(value: string): string { return value.replace(/'/g, "''"); } /** * Build standalone `catalog_items` INSERT statements for items_base entries * that exist (with assets) but have no catalog entry yet. Ids and prices are * resolved the same way the app's own imports do it (autoPriceFurni + category * page + next catalog id), so running the SQL matches an app-managed import. */ export async function generateCatalogSql( entries: MissingCatalogEntry[], ): Promise { const statements: string[] = [ "-- AtomCMS catalog repair", `-- Generated ${new Date().toISOString()}`, `-- ${entries.length} item(s) to add`, "START TRANSACTION;", ]; // The generation pass does not INSERT, so MAX(id) would never advance and // per-entry allocateCatalogItemId would hand out the same id every time. // Read the current max once and hand out sequential ids from there. const [maxIdResult] = (await db.execute(sql` SELECT MAX(id) as maxId FROM catalog_items `)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown]; let nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1; let generated = 0; for (const entry of entries) { let pageId: number; try { pageId = await getOrCreateCategoryPage(entry.classname, entry.itemType); } catch { continue; } const price = autoPriceFurni(entry.classname); statements.push( `INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) VALUES (${nextId}, ${pageId}, ${entry.itemId}, '${escSqlLiteral(entry.classname)}', ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '');`, ); nextId++; generated++; } statements.push("COMMIT;"); return { statements, sql: statements.join("\n"), count: generated }; } /** Execute generated INSERT statements directly against the database. */ export async function applyCatalogSql( statements: string[], ): Promise<{ applied: number; failed: number }> { let applied = 0; let failed = 0; for (const statement of statements) { const trimmed = statement.trim(); if ( !trimmed || trimmed.startsWith("--") || trimmed === "START TRANSACTION;" || trimmed === "COMMIT;" ) { continue; } try { await db.execute(sql.raw(trimmed)); applied++; } catch { failed++; } } return { applied, failed }; } export interface OrphanedCatalogResult { removed: number; cleaned: number; } export interface DuplicateClassnameResult { merged: number; rowsRemoved: number; remapped: number; } /** * Remove catalog_items rows whose item_ids only reference non-existent * items_base entries, and strip orphaned ids from mixed rows. Also deletes * catalog_items_limited rows that pointed at the removed catalog entries so no * new orphans are left behind. */ export async function repairOrphanedCatalog(): Promise { const [itemRows] = (await db.execute(sql` SELECT id FROM items_base `)) as unknown as [Array<{ id: number }>, unknown]; const valid = new Set(); for (const row of itemRows) valid.add(Number(row.id)); const [catalogRows] = (await db.execute(sql` SELECT id, item_ids FROM catalog_items `)) as unknown as [Array<{ id: number; item_ids: string }>, unknown]; let removed = 0; let cleaned = 0; for (const row of catalogRows) { const ids = (row.item_ids ?? "") .split(";") .map((s) => Number(s.trim())) .filter(Boolean); if (ids.length === 0) continue; const kept = ids.filter((id) => valid.has(id)); if (kept.length === ids.length) continue; if (kept.length === 0) { await db.execute(sql` DELETE FROM catalog_items_limited WHERE catalog_item_id = ${row.id} `); await db.execute(sql`DELETE FROM catalog_items WHERE id = ${row.id}`); removed++; } else { await db.execute(sql` UPDATE catalog_items SET item_ids = ${kept.join(";")} WHERE id = ${row.id} `); cleaned++; } } return { removed, cleaned }; } /** * Merge duplicate classnames in items_base into a single canonical row per * classname, remapping every reference (catalog item id lists, player items, * presents, marketplace, etc.) to the canonical id, then delete the duplicate * rows. The canonical id is the one players actually purchase through * catalog_items when present, otherwise the lowest id. */ export async function repairDuplicateClassnames(): Promise { const [itemRows] = (await db.execute(sql` SELECT id, item_name FROM items_base `)) as unknown as [Array<{ id: number; item_name: string }>, unknown]; const byName = new Map(); for (const row of itemRows) { const arr = byName.get(row.item_name) ?? []; arr.push(Number(row.id)); byName.set(row.item_name, arr); } const [catalogRows] = (await db.execute(sql` SELECT item_ids FROM catalog_items `)) as unknown as [Array<{ item_ids: string }>, unknown]; const referenced = new Set(); for (const row of catalogRows) { for (const part of (row.item_ids ?? "").split(";")) { const id = Number(part.trim()); if (id) referenced.add(id); } } const remap = new Map(); let merged = 0; for (const ids of byName.values()) { if (ids.length <= 1) continue; merged++; const sorted = [...ids].sort((a, b) => a - b); const canonical = sorted.find((id) => referenced.has(id)) ?? sorted[0]; for (const id of ids) { if (id !== canonical) remap.set(id, canonical); } } if (remap.size === 0) return { merged: 0, rowsRemoved: 0, remapped: 0 }; let remapped = 0; // Semicolon-separated item_ids columns. const listTables: Array<{ table: string; idCol: string }> = [ { table: "catalog_items", idCol: "id" }, { table: "catalog_items_bc", idCol: "id" }, { table: "logs_shop_purchases", idCol: "id" }, ]; for (const { table, idCol } of listTables) { const [rows] = (await db.execute( sql.raw(`SELECT ${idCol} AS pk, item_ids AS v FROM ${table}`), )) as unknown as [Array<{ pk: number; v: string | null }>, unknown]; for (const row of rows) { if (!row.v) continue; const parts = row.v .split(";") .map((s) => s.trim()) .filter(Boolean); let changed = false; const out: number[] = []; const seen = new Set(); for (const part of parts) { const id = Number(part); const mapped = remap.get(id) ?? id; if (mapped !== id) changed = true; if (seen.has(mapped)) continue; seen.add(mapped); out.push(mapped); } if (!changed) continue; await db.execute( sql.raw( `UPDATE ${table} SET item_ids = '${out.join(";")}' WHERE ${idCol} = ${row.pk}`, ), ); remapped++; } } // Single item_id columns that reference items_base.id. Remap in chunks via // a single CASE statement per table to keep the query count low even with // thousands of duplicate rows. const singleTables = [ "catalog_items_limited", "items", "items_presents", "pet_items", "pet_foods", "pet_drinks", "items_hoppers", "marketplace_items", "items_highscore_data", "items_crackable", "builders_club_items", "recycler_prizes", "gift_wrappers", "youtube_playlists", "room_trade_log_items", "calendar_rewards", "room_trax_playlist", "trax_playlist", "website_event_prizes", "room_templates_items", "crafting_recipes_ingredients", "website_rare_values", "logs_economy", ]; const entries = [...remap.entries()]; const CHUNK = 400; for (const table of singleTables) { for (let i = 0; i < entries.length; i += CHUNK) { const chunk = entries.slice(i, i + CHUNK); const cases = chunk .map(([dup, canonical]) => `WHEN ${dup} THEN ${canonical}`) .join(" "); const dupList = chunk.map(([dup]) => `${dup}`).join(","); try { const [result] = (await db.execute( sql.raw( `UPDATE ${table} SET item_id = CASE item_id ${cases} ELSE item_id END WHERE item_id IN (${dupList})`, ), )) as unknown as [Record, unknown]; remapped += Number(result.affectedRows ?? 0); } catch { // Table may not exist on some hotel schemas — skip it. } } } const dupIds = entries.map(([dup]) => dup); await db.execute( sql.raw(`DELETE FROM items_base WHERE id IN (${dupIds.join(",")})`), ); return { merged, rowsRemoved: dupIds.length, remapped }; } export interface FurniDataRepairResult { added: number; removedDuplicates: number; } function buildMinimalFurniEntry(item: { id: number; item_name: string; public_name: string; }): Record { return { id: item.id, classname: item.item_name, revision: 0, category: "unknown", defaultdir: 0, xdim: 1, ydim: 1, partcolors: { color: [] }, name: item.public_name || item.item_name, description: "", adurl: "", offerid: item.id, buyout: false, rentofferid: -1, rentbuyout: false, bc: false, excludeddynamic: false, customparams: "", specialtype: 0, canstandon: false, cansiton: false, canlayon: false, furniline: "", environment: "", rare: false, }; } /** * Add FurnitureData.json entries for items_base rows that are missing from the * file, and remove duplicate classnames (keeping the first occurrence). */ export async function repairFurniData( items: Array<{ id: number; item_name: string; public_name: string; type: string; }>, ): Promise { if (items.length === 0) return { added: 0, removedDuplicates: 0 }; return withFurniDataLock(async () => { const furniData = (await readFurniData()) as Record< string, { furnitype: Array> } >; // 1) Deduplicate classnames per section (keep the first occurrence). let removedDuplicates = 0; for (const section of ["roomitemtypes", "wallitemtypes"] as const) { const sec = furniData[section]; if (!sec?.furnitype) continue; const seen = new Set(); const deduped = sec.furnitype.filter((e) => { const classname = typeof e.classname === "string" ? e.classname : ""; if (!classname) return true; if (seen.has(classname)) { removedDuplicates++; return false; } seen.add(classname); return true; }); if (deduped.length !== sec.furnitype.length) { furniData[section].furnitype = deduped; } } // 2) Add entries for items_base rows missing from the file. const existing = new Set(); for (const section of ["roomitemtypes", "wallitemtypes"] as const) { for (const e of furniData[section]?.furnitype ?? []) { if (typeof e.classname === "string") existing.add(e.classname); } } const added: Array<{ entry: Record; itemType: string }> = []; for (const item of items) { if (existing.has(item.item_name)) continue; added.push({ entry: buildMinimalFurniEntry(item), itemType: item.type, }); } // 3) Write once if anything changed. if (removedDuplicates > 0 || added.length > 0) { for (const { entry, itemType } of added) { const sectionKey = itemType === "i" ? "wallitemtypes" : "roomitemtypes"; if (!furniData[sectionKey]) { furniData[sectionKey] = { furnitype: [] }; } furniData[sectionKey].furnitype.push(entry); } await writeFurniData(furniData as Record); } return { added: added.length, removedDuplicates }; }); }