"use server"; import { eq, inArray, like, or, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; import { CatalogItems, db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { logAudit } from "@/lib/services/audit"; import { withCatalogExport } from "@/lib/services/catalog-git-queue"; import { allocateCatalogItemId } from "@/lib/services/furni-import"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; import { translateItemsSchema } from "@/lib/validators/catalog"; const CATALOG_ITEM_FIELDS = [ "pageId", "itemIds", "catalogName", "costCredits", "costPoints", "pointsType", "amount", "orderNumber", "offerId", "songId", "limitedSells", "limitedStack", "extradata", "haveOffer", "clubOnly", ] as const; const ITEMS_BASE_FIELDS = [ "publicName", "itemName", "type", "width", "length", "stackHeight", "allowStack", "allowSit", "allowLay", "allowWalk", "allowGift", "allowTrade", "allowRecycle", "allowMarketplaceSell", "allowInventoryStack", "interactionType", "interactionModesCount", "vendingIds", "customparams", "effectIdMale", "effectIdFemale", "clothingOnWalk", ] as const; function pickAllowed( fields: Record, allowed: readonly string[], ): Record { const out: Record = {}; for (const key of allowed) { if (Object.hasOwn(fields, key) && fields[key] !== undefined) { out[key] = fields[key]; } } return out; } /** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */ async function insertCatalogItemRow(data: { pageId: number; itemIds: string; catalogName: string; costCredits: number; costPoints: number; pointsType: number; amount: number; orderNumber: number; offerId: number; songId: number; limitedSells: number; limitedStack: number; extradata: string; haveOffer: string; clubOnly: string; }): Promise { const pageIdStr = String(data.pageId); 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}, ${pageIdStr}, ${data.itemIds}, ${data.catalogName}, ${data.costCredits}, ${data.costPoints}, ${data.pointsType}, ${data.amount}, ${data.orderNumber}, ${data.offerId}, ${data.songId}, ${data.limitedSells}, ${data.limitedStack}, ${data.extradata}, ${data.haveOffer}, ${data.clubOnly} ) `); return nextId; }); } export async function createCatalogItem(data: { pageId: number; itemIds: string; catalogName: string; costCredits: number; costPoints: number; pointsType: number; amount: number; orderNumber: number; offerId: number; limitedSells: number; limitedStack: number; extradata: string; songId: number; haveOffer: "0" | "1"; clubOnly: "0" | "1"; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { let catalogName = data.catalogName.trim(); if (!catalogName) { const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10); if (firstId > 0) { const [base] = 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); } } const id = await insertCatalogItemRow({ ...data, catalogName }); await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, action: "catalog_item_create", description: `Created catalog item #${id}`, targetType: "catalog_item", targetId: id, }); revalidatePath("/admin/catalog"); return { ok: true as const, data: { id } }; }); } /** Bulk create with one RCON refresh at the end. */ export async function bulkCreateCatalogItems({ pageId, rows, }: { pageId: number; rows: Array<{ baseId: number; credits?: number; points?: number; pointsType?: number; }>; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { if (rows.length === 0) { return { ok: true as const, data: { created: 0, failed: 0 } }; } if (rows.length > 500) { return { ok: false as const, error: "Max 500 items per bulk import" }; } const baseIds = [...new Set(rows.map((r) => r.baseId))]; const bases = await db .select({ id: ItemsBase.id, publicName: ItemsBase.publicName, itemName: ItemsBase.itemName, }) .from(ItemsBase) .where(inArray(ItemsBase.id, baseIds)); const baseMap = new Map(bases.map((b) => [b.id, b])); let created = 0; let failed = 0; for (const row of rows) { const base = baseMap.get(row.baseId); if (!base) { failed++; continue; } try { await insertCatalogItemRow({ pageId, itemIds: String(row.baseId), catalogName: base.publicName || base.itemName || String(row.baseId), costCredits: row.credits ?? 0, costPoints: row.points ?? 0, pointsType: row.pointsType ?? 0, amount: 1, limitedSells: 0, limitedStack: 0, orderNumber: 1, offerId: -1, songId: 0, haveOffer: "1", clubOnly: "0", extradata: "", }); created++; } catch { failed++; } } if (created > 0) { await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, action: "catalog_items_bulk_create", description: `Bulk imported ${created} catalog item(s) on page #${pageId}`, targetType: "catalog_page", targetId: pageId, }); revalidatePath("/admin/catalog"); } return { ok: true as const, data: { created, failed } }; }); } export async function deleteCatalogItems({ ids }: { ids: number[] }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids)); await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, action: "catalog_items_delete", description: `Deleted catalog items: ${ids.join(", ")}`, targetType: "catalog_item", }); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; }); } export async function moveCatalogItems({ ids, targetPageId, }: { ids: number[]; targetPageId: number; }) { await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { if (ids.length === 0) { return { ok: true as const, data: {} }; } const pageIdStr = String(targetPageId); await db.execute(sql` UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id IN (${sql.join( ids.map((id) => sql`${id}`), sql`, `, )}) `); await rcon.updateCatalog(); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; }); } export async function reorderCatalogItems({ orders, }: { orders: Array<{ id: number; orderNumber: number }>; }) { await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { for (const { id, orderNumber } of orders) { await db .update(CatalogItems) .set({ orderNumber }) .where(eq(CatalogItems.id, id)); } await rcon.updateCatalog(); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; }); } export async function updateCatalogItem({ id, catalogFields, baseItem, }: { id: number; catalogFields: Record; baseItem?: { id: number; fields: Record }; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS); if (Object.keys(safeCatalog).length === 0 && !baseItem) { return { ok: false as const, error: "No valid fields to update" }; } // page_id is often VARCHAR — update it via raw SQL when present. const pageIdRaw = safeCatalog.pageId; if (pageIdRaw !== undefined) { const pageIdStr = String(pageIdRaw); await db.execute(sql` UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id} `); delete safeCatalog.pageId; } if (Object.keys(safeCatalog).length > 0) { await db .update(CatalogItems) .set(safeCatalog as Partial) .where(eq(CatalogItems.id, id)); } if (baseItem) { const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS); if (Object.keys(safeBase).length > 0) { await db .update(ItemsBase) .set(safeBase as Partial) .where(eq(ItemsBase.id, baseItem.id)); } } await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, action: "catalog_item_update", description: `Updated catalog item #${id}`, targetType: "catalog_item", targetId: id, }); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; }); } export async function translateCatalogItems(input: { /** `id` is items_base.id (not catalog_items.id) */ items: Array<{ id: number; publicName: string; description?: string }>; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { const parsed = translateItemsSchema.safeParse(input); if (!parsed.success) { return { ok: false as const, error: parsed.error.issues[0]?.message ?? "Invalid translate payload", }; } const { items } = parsed.data; const { invalidateFurniDataCache } = await import( "@/lib/services/catalog-items-loader" ); const { patchFurniEntryNames } = await import("@/lib/services/furni-data"); let namesUpdated = 0; let descriptionsUpdated = 0; const furniPatches: Array<{ classname: string; itemType: string; name?: string; description?: string; spriteId?: number; createIfMissing?: boolean; }> = []; for (const item of items) { 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, item.id)) .limit(1); if (!base) continue; const nextName = item.publicName?.trim() ?? ""; const nextDesc = item.description ?? ""; const nameChanged = nextName !== "" && nextName !== (base.publicName ?? ""); if (nameChanged) { await db .update(ItemsBase) .set({ publicName: nextName }) .where(eq(ItemsBase.id, base.id)); const idStr = String(base.id); const related = await db .select({ id: CatalogItems.id, catalogName: CatalogItems.catalogName, }) .from(CatalogItems) .where( or( eq(CatalogItems.itemIds, idStr), like(CatalogItems.itemIds, `${idStr};%`), like(CatalogItems.itemIds, `%;${idStr};%`), like(CatalogItems.itemIds, `%;${idStr}`), ), ); for (const row of related) { if (row.catalogName !== nextName) { await db .update(CatalogItems) .set({ catalogName: nextName }) .where(eq(CatalogItems.id, row.id)); } } namesUpdated++; } if (nextDesc !== "" || nameChanged) { descriptionsUpdated += nextDesc !== "" ? 1 : 0; furniPatches.push({ classname: base.itemName, itemType: base.type || "s", name: nextName || base.publicName || base.itemName, description: nextDesc, spriteId: base.spriteId, createIfMissing: true, }); } } const furniResult = furniPatches.length > 0 ? await patchFurniEntryNames(furniPatches) : { updated: 0, inserted: 0 }; if (furniResult.updated > 0 || furniResult.inserted > 0) { invalidateFurniDataCache(); } await rcon.updateCatalog(); await logAudit({ userId: staff.id, action: "items_base_translate", target: "ItemsBase", after: { namesUpdated, descriptionsUpdated, furniDataUpdated: furniResult.updated > 0, furniDataInserted: furniResult.inserted, }, }); revalidatePath("/admin/catalog"); return { ok: true as const, data: { namesUpdated, descriptionsUpdated, furniDataUpdated: furniResult.updated, furniDataInserted: furniResult.inserted, updated: items.length, }, }; }); }