import "server-only"; import { eq, inArray, like, or, sql } from "drizzle-orm"; import type { ResultSetHeader } from "mysql2"; import { CatalogItems, CatalogItemsBc, CatalogPages, CatalogPagesBc, db, ItemsBase, } from "@/lib/db"; import { allocateCatalogItemId } from "@/lib/services/furni-import"; import { rcon } from "@/lib/services/rcon"; import { action, flag, hasOwn, integer, integerArray, notFound, positiveInteger, rawText, record, requirePatch, safeOutput, text, validation, } from "./mutation-runtime-input"; import type { EconomyMutationContext, EconomyMutationOperation, EconomyMutationSnapshot, } from "./mutations"; import { EconomyCommittedExternalFailure } from "./mutations-production"; type EconomyDatabase = typeof db; async function synchronizeCatalog( snapshot: EconomyMutationSnapshot, ): Promise { try { const delivered = await rcon.updateCatalog(); if (!delivered) throw new Error("catalog sync rejected"); return snapshot; } catch { throw new EconomyCommittedExternalFailure(snapshot); } } function pageValues(data: Record, builderClub: boolean) { const values: Record = {}; if (hasOwn(data, "caption")) values.caption = text(data.caption, 128, true); if (hasOwn(data, "parentId")) values.parentId = integer(data.parentId, -1); if (hasOwn(data, "pageLayout")) values.pageLayout = text(data.pageLayout, 26, true); if (hasOwn(data, "enabled")) values.enabled = flag(data.enabled); if (hasOwn(data, "visible")) values.visible = flag(data.visible); if (hasOwn(data, "orderNum")) values.orderNum = integer(data.orderNum); if (hasOwn(data, "iconImage")) values.iconImage = integer(data.iconImage); if (hasOwn(data, "iconColor")) values.iconColor = integer(data.iconColor); if (hasOwn(data, "pageHeadline")) values.pageHeadline = text(data.pageHeadline, 1024); if (hasOwn(data, "pageTeaser")) values.pageTeaser = text(data.pageTeaser, 64); if (hasOwn(data, "pageSpecial")) values.pageSpecial = text(data.pageSpecial, 2048); if (hasOwn(data, "pageText1")) values.pageText1 = rawText(data.pageText1); if (hasOwn(data, "pageText2")) values.pageText2 = rawText(data.pageText2); if (hasOwn(data, "pageTextDetails")) values.pageTextDetails = rawText(data.pageTextDetails); if (hasOwn(data, "pageTextTeaser")) values.pageTextTeaser = rawText(data.pageTextTeaser); if (!builderClub) { if (hasOwn(data, "minRank")) values.minRank = integer(data.minRank, 1); if (hasOwn(data, "clubOnly")) values.clubOnly = flag(data.clubOnly); if (hasOwn(data, "vipOnly")) values.vipOnly = flag(data.vipOnly); if (hasOwn(data, "includes")) values.includes = text(data.includes, 128); if (hasOwn(data, "captionSave")) values.captionSave = text(data.captionSave, 25); } return values; } async function catalogPageChange( input: unknown, ): Promise { const data = record(input); const selectedAction = action(data, [ "create", "update", "delete", "toggle-enabled", "toggle-visible", ]); const snapshot = await db.transaction(async (transaction) => { if (selectedAction === "create") { const caption = text(data.caption, 128, true); const values = pageValues(data, false); const [result] = await transaction.insert(CatalogPages).values({ caption, captionSave: text(data.captionSave ?? caption, 25, true), parentId: integer(data.parentId ?? -1, -1), pageLayout: text(data.pageLayout ?? "default_3x3", 26, true), iconColor: integer(data.iconColor ?? 0), iconImage: integer(data.iconImage ?? 0), minRank: integer(data.minRank ?? 1, 1), orderNum: integer(data.orderNum ?? 0), visible: hasOwn(data, "visible") ? flag(data.visible) : "1", enabled: hasOwn(data, "enabled") ? flag(data.enabled) : "1", clubOnly: hasOwn(data, "clubOnly") ? flag(data.clubOnly) : "0", vipOnly: hasOwn(data, "vipOnly") ? flag(data.vipOnly) : "0", pageHeadline: text(data.pageHeadline, 1024), pageTeaser: text(data.pageTeaser, 64), includes: text(data.includes, 128), ...(values as Partial), }); const id = String((result as ResultSetHeader).insertId); return { before: null, after: { id, caption }, output: { id } }; } const id = positiveInteger(data.id); const [existing] = await transaction .select({ id: CatalogPages.id, caption: CatalogPages.caption, parentId: CatalogPages.parentId, enabled: CatalogPages.enabled, visible: CatalogPages.visible, orderNum: CatalogPages.orderNum, }) .from(CatalogPages) .where(eq(CatalogPages.id, id)) .limit(1); if (!existing) throw notFound(); const before = { id: String(id), caption: existing.caption, parentId: existing.parentId, enabled: existing.enabled, visible: existing.visible, orderNum: existing.orderNum, }; if (selectedAction === "delete") { await deleteCatalogTree( transaction as unknown as EconomyDatabase, id, "reparent", ); return { before, after: null }; } if ( selectedAction === "toggle-enabled" || selectedAction === "toggle-visible" ) { const field = selectedAction === "toggle-enabled" ? "enabled" : "visible"; const next = existing[field] === "1" ? "0" : "1"; await transaction .update(CatalogPages) .set({ [field]: next }) .where(eq(CatalogPages.id, id)); return { before, after: { ...before, [field]: next } }; } const values = pageValues(data, false) as Partial< typeof CatalogPages.$inferInsert >; if (values.caption && !values.captionSave) values.captionSave = values.caption.slice(0, 25); requirePatch(values as Record); await transaction .update(CatalogPages) .set(values) .where(eq(CatalogPages.id, id)); return { before, after: { ...before, ...values } }; }); return synchronizeCatalog(snapshot); } async function moveCatalogPage( connection: EconomyDatabase, pageId: number, newParentId: number, ): Promise { if (newParentId > 0) { let currentId = newParentId; for (let index = 0; index < 50; index += 1) { if (currentId === pageId) throw validation({ newParentId: ["circular hierarchy"] }); const [parent] = await connection .select({ parentId: CatalogPages.parentId }) .from(CatalogPages) .where(eq(CatalogPages.id, currentId)) .limit(1); if (!parent || parent.parentId <= 0) break; currentId = parent.parentId; } } await connection .update(CatalogPages) .set({ parentId: newParentId }) .where(eq(CatalogPages.id, pageId)); } async function catalogPageReorder( input: unknown, ): Promise { const data = record(input); const pageId = positiveInteger(data.pageId ?? data.id); const newOrderNum = integer(data.newOrderNum ?? data.orderNum); const snapshot = await db.transaction(async (transaction) => { const [existing] = await transaction .select({ parentId: CatalogPages.parentId, orderNum: CatalogPages.orderNum, }) .from(CatalogPages) .where(eq(CatalogPages.id, pageId)) .limit(1); if (!existing) throw notFound(); const newParentId = hasOwn(data, "newParentId") ? integer(data.newParentId, -1) : existing.parentId; if (newParentId !== existing.parentId) await moveCatalogPage( transaction as unknown as EconomyDatabase, pageId, newParentId, ); await transaction .update(CatalogPages) .set({ orderNum: newOrderNum }) .where(eq(CatalogPages.id, pageId)); return { before: { id: String(pageId), parentId: existing.parentId, orderNum: existing.orderNum, }, after: { id: String(pageId), parentId: newParentId, orderNum: newOrderNum, }, }; }); return synchronizeCatalog(snapshot); } async function deleteCatalogTree( connection: EconomyDatabase, pageId: number, mode: "cascade" | "reparent", ): Promise<{ deletedPages: number; movedChildren: number }> { const [page] = await connection .select({ parentId: CatalogPages.parentId }) .from(CatalogPages) .where(eq(CatalogPages.id, pageId)) .limit(1); if (!page) throw notFound(); if (mode === "reparent") { const [updated] = await connection .update(CatalogPages) .set({ parentId: page.parentId }) .where(eq(CatalogPages.parentId, pageId)); await connection.execute( sql`DELETE FROM catalog_items WHERE page_id = ${String(pageId)}`, ); await connection.delete(CatalogPages).where(eq(CatalogPages.id, pageId)); return { deletedPages: 1, movedChildren: updated.affectedRows }; } const toDelete = [pageId]; let queue = [pageId]; while (queue.length > 0) { const children = await connection .select({ id: CatalogPages.id }) .from(CatalogPages) .where(inArray(CatalogPages.parentId, queue)); queue = children.map((child) => child.id); toDelete.push(...queue); } await connection.execute( sql`DELETE FROM catalog_items WHERE page_id IN (${sql.join(toDelete.map(String), sql`, `)})`, ); await connection .delete(CatalogPages) .where(inArray(CatalogPages.id, toDelete)); return { deletedPages: toDelete.length, movedChildren: 0 }; } async function catalogPageDeleteTree( input: unknown, ): Promise { const data = record(input); const pageId = positiveInteger(data.pageId ?? data.id); const mode = text(data.mode ?? "reparent", 16, true); if (mode !== "reparent" && mode !== "cascade") throw validation(); const result = await db.transaction((transaction) => deleteCatalogTree(transaction as unknown as EconomyDatabase, pageId, mode), ); return synchronizeCatalog({ before: { id: String(pageId) }, after: null, output: result, }); } async function bcPageChange(input: unknown): Promise { const data = record(input); const selectedAction = action(data, [ "create", "update", "delete", "toggle-enabled", "toggle-visible", ]); const snapshot = await db.transaction(async (transaction) => { if (selectedAction === "create") { const caption = text(data.caption, 128, true); const [result] = await transaction.insert(CatalogPagesBc).values({ caption, parentId: integer(data.parentId ?? -1, -1), pageLayout: text(data.pageLayout ?? "default_3x3", 26, true), iconColor: integer(data.iconColor ?? 0), iconImage: integer(data.iconImage ?? 0), orderNum: integer(data.orderNum ?? 0), visible: hasOwn(data, "visible") ? flag(data.visible) : "1", enabled: hasOwn(data, "enabled") ? flag(data.enabled) : "1", pageHeadline: text(data.pageHeadline, 1024), pageTeaser: text(data.pageTeaser, 64), }); const id = String((result as ResultSetHeader).insertId); return { before: null, after: { id, caption }, output: { id } }; } const id = positiveInteger(data.id); const [existing] = await transaction .select({ caption: CatalogPagesBc.caption, parentId: CatalogPagesBc.parentId, enabled: CatalogPagesBc.enabled, visible: CatalogPagesBc.visible, orderNum: CatalogPagesBc.orderNum, }) .from(CatalogPagesBc) .where(eq(CatalogPagesBc.id, id)) .limit(1); if (!existing) throw notFound(); const before = { id: String(id), ...existing }; if (selectedAction === "delete") { await deleteBcTree( transaction as unknown as EconomyDatabase, id, "reparent", ); return { before, after: null }; } if ( selectedAction === "toggle-enabled" || selectedAction === "toggle-visible" ) { const field = selectedAction === "toggle-enabled" ? "enabled" : "visible"; const next = existing[field] === "1" ? "0" : "1"; await transaction .update(CatalogPagesBc) .set({ [field]: next }) .where(eq(CatalogPagesBc.id, id)); return { before, after: { ...before, [field]: next } }; } const values = pageValues(data, true) as Partial< typeof CatalogPagesBc.$inferInsert >; requirePatch(values as Record); await transaction .update(CatalogPagesBc) .set(values) .where(eq(CatalogPagesBc.id, id)); return { before, after: { ...before, ...values } }; }); return synchronizeCatalog(snapshot); } async function moveBcPage( connection: EconomyDatabase, pageId: number, newParentId: number, ): Promise { if (newParentId > 0) { let currentId = newParentId; for (let index = 0; index < 50; index += 1) { if (currentId === pageId) throw validation({ newParentId: ["circular hierarchy"] }); const [parent] = await connection .select({ parentId: CatalogPagesBc.parentId }) .from(CatalogPagesBc) .where(eq(CatalogPagesBc.id, currentId)) .limit(1); if (!parent || parent.parentId <= 0) break; currentId = parent.parentId; } } await connection .update(CatalogPagesBc) .set({ parentId: newParentId }) .where(eq(CatalogPagesBc.id, pageId)); } async function bcPageReorder(input: unknown): Promise { const data = record(input); const pageId = positiveInteger(data.pageId ?? data.id); const newOrderNum = integer(data.newOrderNum ?? data.orderNum); const snapshot = await db.transaction(async (transaction) => { const [existing] = await transaction .select({ parentId: CatalogPagesBc.parentId, orderNum: CatalogPagesBc.orderNum, }) .from(CatalogPagesBc) .where(eq(CatalogPagesBc.id, pageId)) .limit(1); if (!existing) throw notFound(); const newParentId = hasOwn(data, "newParentId") ? integer(data.newParentId, -1) : existing.parentId; if (newParentId !== existing.parentId) await moveBcPage( transaction as unknown as EconomyDatabase, pageId, newParentId, ); await transaction .update(CatalogPagesBc) .set({ orderNum: newOrderNum }) .where(eq(CatalogPagesBc.id, pageId)); return { before: { id: String(pageId), ...existing }, after: { id: String(pageId), parentId: newParentId, orderNum: newOrderNum, }, }; }); return synchronizeCatalog(snapshot); } async function deleteBcTree( connection: EconomyDatabase, pageId: number, mode: "cascade" | "reparent", ) { const [page] = await connection .select({ parentId: CatalogPagesBc.parentId }) .from(CatalogPagesBc) .where(eq(CatalogPagesBc.id, pageId)) .limit(1); if (!page) throw notFound(); if (mode === "reparent") { const [updated] = await connection .update(CatalogPagesBc) .set({ parentId: page.parentId }) .where(eq(CatalogPagesBc.parentId, pageId)); await connection .delete(CatalogItemsBc) .where(eq(CatalogItemsBc.pageId, pageId)); await connection .delete(CatalogPagesBc) .where(eq(CatalogPagesBc.id, pageId)); return { deletedPages: 1, movedChildren: updated.affectedRows }; } const toDelete = [pageId]; let queue = [pageId]; while (queue.length > 0) { const children = await connection .select({ id: CatalogPagesBc.id }) .from(CatalogPagesBc) .where(inArray(CatalogPagesBc.parentId, queue)); queue = children.map((child) => child.id); toDelete.push(...queue); } await connection .delete(CatalogItemsBc) .where(inArray(CatalogItemsBc.pageId, toDelete)); await connection .delete(CatalogPagesBc) .where(inArray(CatalogPagesBc.id, toDelete)); return { deletedPages: toDelete.length, movedChildren: 0 }; } async function bcPageDeleteTree( input: unknown, ): Promise { const data = record(input); const pageId = positiveInteger(data.pageId ?? data.id); const mode = text(data.mode ?? "reparent", 16, true); if (mode !== "reparent" && mode !== "cascade") throw validation(); const result = await db.transaction((transaction) => deleteBcTree(transaction as unknown as EconomyDatabase, pageId, mode), ); return synchronizeCatalog({ before: { id: String(pageId) }, after: null, output: result, }); } async function bcItemChange(input: unknown): Promise { const data = record(input); const selectedAction = action(data, ["create", "update", "delete"]); const snapshot = await db.transaction(async (transaction) => { if (selectedAction === "create") { const values = { pageId: positiveInteger(data.pageId), itemIds: text(data.itemIds, 666, true), catalogName: text(data.catalogName, 100, true), orderNumber: integer(data.orderNumber ?? 1), extradata: text(data.extradata, 500), }; const [result] = await transaction.insert(CatalogItemsBc).values(values); const id = String((result as ResultSetHeader).insertId); return { before: null, after: { id, ...values }, output: { id } }; } const id = positiveInteger(data.id); const [existing] = await transaction .select() .from(CatalogItemsBc) .where(eq(CatalogItemsBc.id, id)) .limit(1); if (!existing) throw notFound(); const before = { id: String(id), pageId: existing.pageId, itemIds: existing.itemIds, catalogName: existing.catalogName, orderNumber: existing.orderNumber, }; if (selectedAction === "delete") { await transaction.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id)); return { before, after: null }; } const values: Partial = {}; if (hasOwn(data, "pageId")) values.pageId = positiveInteger(data.pageId); if (hasOwn(data, "itemIds")) values.itemIds = text(data.itemIds, 666, true); if (hasOwn(data, "catalogName")) values.catalogName = text(data.catalogName, 100, true); if (hasOwn(data, "orderNumber")) values.orderNumber = integer(data.orderNumber); if (hasOwn(data, "extradata")) values.extradata = text(data.extradata, 500); requirePatch(values as Record); await transaction .update(CatalogItemsBc) .set(values) .where(eq(CatalogItemsBc.id, id)); return { before, after: { ...before, ...values } }; }); return synchronizeCatalog(snapshot); } const CATALOG_ITEM_TEXT_FIELDS = new Map([ ["itemIds", 666], ["catalogName", 100], ["extradata", 500], ] as const); const CATALOG_ITEM_NUMBER_FIELDS = [ "costCredits", "costPoints", "pointsType", "amount", "orderNumber", "offerId", "songId", "limitedSells", "limitedStack", ] as const; function catalogItemValues(data: Record) { const values: Record = {}; for (const [key, maximum] of CATALOG_ITEM_TEXT_FIELDS) if (hasOwn(data, key)) values[key] = text(data[key], maximum, key !== "extradata"); for (const key of CATALOG_ITEM_NUMBER_FIELDS) if (hasOwn(data, key)) values[key] = integer(data[key], key === "offerId" ? -1 : 0); if (hasOwn(data, "haveOffer")) values.haveOffer = flag(data.haveOffer); if (hasOwn(data, "clubOnly")) values.clubOnly = flag(data.clubOnly); return values; } async function insertCatalogItem( data: Record, ): Promise { const pageId = positiveInteger(data.pageId); let catalogName = text(data.catalogName, 100); const itemIds = text(data.itemIds, 666, true); if (!catalogName) { const firstId = Number.parseInt(itemIds.split(";")[0] ?? "", 10); const [base] = firstId > 0 ? await db .select({ publicName: ItemsBase.publicName, itemName: ItemsBase.itemName, }) .from(ItemsBase) .where(eq(ItemsBase.id, firstId)) .limit(1) : []; catalogName = base?.publicName || base?.itemName || String(firstId); } return allocateCatalogItemId(async (nextId) => { await db.execute( sql`INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, song_id, limited_sells, limited_stack, extradata, have_offer, club_only) VALUES (${nextId}, ${String(pageId)}, ${itemIds}, ${catalogName}, ${integer(data.costCredits ?? 0)}, ${integer(data.costPoints ?? 0)}, ${integer(data.pointsType ?? 0)}, ${integer(data.amount ?? 1, 1)}, ${integer(data.orderNumber ?? 1)}, ${integer(data.offerId ?? -1, -1)}, ${integer(data.songId ?? 0)}, ${integer(data.limitedSells ?? 0)}, ${integer(data.limitedStack ?? 0)}, ${text(data.extradata, 500)}, ${hasOwn(data, "haveOffer") ? flag(data.haveOffer) : "1"}, ${hasOwn(data, "clubOnly") ? flag(data.clubOnly) : "0"})`, ); return nextId; }); } async function catalogItemChange( input: unknown, ): Promise { const data = record(input); const selectedAction = action(data, ["create", "update", "delete"]); if (selectedAction === "create") { const id = await insertCatalogItem(data); return synchronizeCatalog({ before: null, after: { id: String(id), pageId: positiveInteger(data.pageId), itemIds: text(data.itemIds, 666, true), }, output: { id: String(id) }, }); } if (selectedAction === "delete" && Array.isArray(data.ids)) { const ids = integerArray(data.ids); if (ids.length === 0) throw validation(); await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids)); return synchronizeCatalog({ before: { ids: ids.map(String) }, after: null, output: { deleted: ids.length }, }); } const id = positiveInteger(data.id); const snapshot = await db.transaction(async (transaction) => { const [existing] = await transaction .select({ id: CatalogItems.id, pageId: CatalogItems.pageId, itemIds: CatalogItems.itemIds, catalogName: CatalogItems.catalogName, costCredits: CatalogItems.costCredits, costPoints: CatalogItems.costPoints, amount: CatalogItems.amount, orderNumber: CatalogItems.orderNumber, }) .from(CatalogItems) .where(eq(CatalogItems.id, id)) .limit(1); if (!existing) throw notFound(); const before = { ...existing, id: String(id) }; if (selectedAction === "delete") { await transaction.delete(CatalogItems).where(eq(CatalogItems.id, id)); return { before, after: null }; } const catalogFields = record(data.catalogFields ?? data); const values = catalogItemValues(catalogFields) as Partial< typeof CatalogItems.$inferInsert >; if (hasOwn(catalogFields, "pageId")) values.pageId = positiveInteger(catalogFields.pageId); const baseItem = data.baseItem && typeof data.baseItem === "object" ? record(data.baseItem) : null; if (Object.keys(values).length === 0 && !baseItem) throw validation(); if (Object.keys(values).length > 0) await transaction .update(CatalogItems) .set(values) .where(eq(CatalogItems.id, id)); if (baseItem) await updateItemsBaseRecord( transaction as unknown as EconomyDatabase, positiveInteger(baseItem.id), record(baseItem.fields), ); return { before, after: { ...before, ...values } }; }); return synchronizeCatalog(snapshot); } async function catalogItemBulkCreate( input: unknown, ): Promise { const data = record(input); const pageId = positiveInteger(data.pageId); if (!Array.isArray(data.rows) || data.rows.length > 500) throw validation(); let created = 0; let failed = 0; for (const candidate of data.rows) { try { const row = record(candidate); const baseId = positiveInteger(row.baseId); const [base] = await db .select({ publicName: ItemsBase.publicName, itemName: ItemsBase.itemName, }) .from(ItemsBase) .where(eq(ItemsBase.id, baseId)) .limit(1); if (!base) { failed += 1; continue; } await insertCatalogItem({ pageId, itemIds: String(baseId), catalogName: base.publicName || base.itemName || String(baseId), costCredits: row.credits ?? 0, costPoints: row.points ?? 0, pointsType: row.pointsType ?? 0, amount: 1, orderNumber: 1, offerId: -1, songId: 0, limitedSells: 0, limitedStack: 0, extradata: "", haveOffer: "1", clubOnly: "0", }); created += 1; } catch { failed += 1; } } return synchronizeCatalog({ before: null, after: { pageId: String(pageId), created, failed }, output: { created, failed }, }); } async function catalogItemMove( input: unknown, ): Promise { const data = record(input); const ids = integerArray(data.ids); const targetPageId = positiveInteger(data.targetPageId); if (ids.length === 0) throw validation(); await db.execute( sql`UPDATE catalog_items SET page_id = ${String(targetPageId)} WHERE id IN (${sql.join( ids.map((id) => sql`${id}`), sql`, `, )})`, ); return synchronizeCatalog({ before: { ids: ids.map(String) }, after: { ids: ids.map(String), pageId: String(targetPageId) }, }); } async function catalogItemReorder( input: unknown, ): Promise { const data = record(input); if ( !Array.isArray(data.orders) || data.orders.length === 0 || data.orders.length > 500 ) throw validation(); const orders = data.orders.map((candidate) => { const row = record(candidate); return { id: positiveInteger(row.id), orderNumber: integer(row.orderNumber), }; }); await db.transaction(async (transaction) => { for (const row of orders) await transaction .update(CatalogItems) .set({ orderNumber: row.orderNumber }) .where(eq(CatalogItems.id, row.id)); }); return synchronizeCatalog({ before: { ids: orders.map((row) => String(row.id)) }, after: { orders }, }); } async function catalogItemTranslate( input: unknown, ): Promise { const data = record(input); if ( !Array.isArray(data.items) || data.items.length === 0 || data.items.length > 500 ) throw validation(); const furniPatches: Array<{ classname: string; itemType: string; name?: string; description?: string; spriteId?: number; createIfMissing?: boolean; }> = []; let namesUpdated = 0; let descriptionsUpdated = 0; for (const candidate of data.items) { const item = record(candidate); const id = positiveInteger(item.id); const [base] = await db .select({ id: ItemsBase.id, publicName: ItemsBase.publicName, itemName: ItemsBase.itemName, type: ItemsBase.type, spriteId: ItemsBase.spriteId, }) .from(ItemsBase) .where(eq(ItemsBase.id, id)) .limit(1); if (!base) continue; const nextName = text(item.publicName, 56); const nextDescription = text(item.description, 500); if (nextName && nextName !== base.publicName) { await db .update(ItemsBase) .set({ publicName: nextName }) .where(eq(ItemsBase.id, id)); const idText = String(id); const related = await db .select({ id: CatalogItems.id }) .from(CatalogItems) .where( or( eq(CatalogItems.itemIds, idText), like(CatalogItems.itemIds, `${idText};%`), like(CatalogItems.itemIds, `%;${idText};%`), like(CatalogItems.itemIds, `%;${idText}`), ), ); for (const row of related) await db .update(CatalogItems) .set({ catalogName: nextName }) .where(eq(CatalogItems.id, row.id)); namesUpdated += 1; } if (nextDescription || nextName) { if (nextDescription) descriptionsUpdated += 1; furniPatches.push({ classname: base.itemName, itemType: base.type || "s", name: nextName || base.publicName || base.itemName, description: nextDescription, spriteId: base.spriteId, createIfMissing: true, }); } } const snapshot: EconomyMutationSnapshot = { before: null, after: { namesUpdated, descriptionsUpdated }, output: { namesUpdated, descriptionsUpdated }, }; try { let furniDataUpdated = 0; let furniDataInserted = 0; if (furniPatches.length > 0) { const [{ patchFurniEntryNames }, { invalidateFurniDataCache }] = await Promise.all([ import("@/lib/services/furni-data"), import("@/lib/services/catalog-items-loader"), ]); const result = await patchFurniEntryNames(furniPatches); if (result.updated > 0 || result.inserted > 0) invalidateFurniDataCache(); furniDataUpdated = result.updated; furniDataInserted = result.inserted; } return synchronizeCatalog({ ...snapshot, output: { namesUpdated, descriptionsUpdated, furniDataUpdated, furniDataInserted, }, }); } catch (error) { if (error instanceof EconomyCommittedExternalFailure) throw error; throw new EconomyCommittedExternalFailure(snapshot); } } const ITEMS_BASE_TEXT_FIELDS = new Map([ ["publicName", 56], ["itemName", 70], ["type", 3], ["interactionType", 500], ["vendingIds", 255], ["multiheight", 50], ["customparams", 25_600], ["clothingOnWalk", 255], ] as const); const ITEMS_BASE_NUMBER_FIELDS = [ "spriteId", "width", "length", "allowStack", "allowSit", "allowLay", "allowWalk", "allowGift", "allowTrade", "allowRecycle", "allowMarketplaceSell", "allowInventoryStack", "interactionModesCount", "effectIdMale", "effectIdFemale", ] as const; async function updateItemsBaseRecord( connection: EconomyDatabase, id: number, fields: Record, ) { const values: Record = {}; for (const [key, maximum] of ITEMS_BASE_TEXT_FIELDS) if (hasOwn(fields, key)) values[key] = text(fields[key], maximum, key === "itemName"); for (const key of ITEMS_BASE_NUMBER_FIELDS) if (hasOwn(fields, key)) values[key] = integer(fields[key]); if (hasOwn(fields, "stackHeight")) { const height = Number(fields.stackHeight); if (!Number.isFinite(height) || height < 0) throw validation(); values.stackHeight = height; } requirePatch(values); await connection .update(ItemsBase) .set(values as Partial) .where(eq(ItemsBase.id, id)); return values; } async function itemsBaseUpdate( input: unknown, ): Promise { const data = record(input); const id = positiveInteger(data.id); const snapshot = await db.transaction(async (transaction) => { const [existing] = await transaction .select({ id: ItemsBase.id, publicName: ItemsBase.publicName, itemName: ItemsBase.itemName, interactionType: ItemsBase.interactionType, }) .from(ItemsBase) .where(eq(ItemsBase.id, id)) .limit(1); if (!existing) throw notFound(); const fields = data.fields && typeof data.fields === "object" ? record(data.fields) : data; const values = await updateItemsBaseRecord( transaction as unknown as EconomyDatabase, id, fields, ); return { before: { ...existing, id: String(id) }, after: { ...existing, id: String(id), ...values }, }; }); return synchronizeCatalog(snapshot); } async function maintenance( operation: EconomyMutationOperation, input: unknown, ): Promise { const data = record(input); const service = await import("@/lib/services/furni-maintenance"); let result: unknown; switch (operation) { case "maintenance.fix-offers": result = await service.fixCatalogOffers(); break; case "maintenance.fix-everything": result = await service.fixEverything({ dedupePages: data.dedupePages !== false, }); break; case "maintenance.fix-sprite-ids": result = await service.fixSpriteIds(); break; case "maintenance.reconcile-ids": result = await service.reconcileIds(); break; case "maintenance.align-ids": result = await service.forceItemsBaseIdsToFurnidata(data.apply !== false); break; case "maintenance.remove-duplicates": result = await service.removeDuplicates(data.dedupePages !== false); break; default: throw validation(); } const output = safeOutput(result); return synchronizeCatalog({ before: null, after: { operation, completed: true }, output, }); } const HANDLERS: Partial< Record< EconomyMutationOperation, ( input: unknown, context: EconomyMutationContext, ) => Promise > > = { "catalog-page.change": (input) => catalogPageChange(input), "catalog-page.reorder": (input) => catalogPageReorder(input), "catalog-page.delete-tree": (input) => catalogPageDeleteTree(input), "bc-page.change": (input) => bcPageChange(input), "bc-page.reorder": (input) => bcPageReorder(input), "bc-page.delete-tree": (input) => bcPageDeleteTree(input), "bc-item.change": (input) => bcItemChange(input), "catalog-item.change": (input) => catalogItemChange(input), "catalog-item.bulk-create": (input) => catalogItemBulkCreate(input), "catalog-item.move": (input) => catalogItemMove(input), "catalog-item.reorder": (input) => catalogItemReorder(input), "catalog-item.translate": (input) => catalogItemTranslate(input), "items-base.update": (input) => itemsBaseUpdate(input), "maintenance.fix-offers": (input) => maintenance("maintenance.fix-offers", input), "maintenance.fix-everything": (input) => maintenance("maintenance.fix-everything", input), "maintenance.fix-sprite-ids": (input) => maintenance("maintenance.fix-sprite-ids", input), "maintenance.reconcile-ids": (input) => maintenance("maintenance.reconcile-ids", input), "maintenance.align-ids": (input) => maintenance("maintenance.align-ids", input), "maintenance.remove-duplicates": (input) => maintenance("maintenance.remove-duplicates", input), }; export async function executeEconomyCatalogMutation( operation: EconomyMutationOperation, input: unknown, context: EconomyMutationContext, ): Promise { const handler = HANDLERS[operation]; return handler ? handler(input, context) : null; }