"use server"; import { eq, inArray, like, or, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { offerPatchSchema } from "@/features/catalog/domain/offer-input"; import { createOfferCommand, moveOffersCommand, reorderOffersCommand, updateOfferCommand, } from "@/features/catalog/server/offer-commands"; import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; 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 { logStaffActivity } from "@/lib/services/staff-activity"; import { translateItemsSchema } from "@/lib/validators/catalog"; /** 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); offerPatchSchema.required().parse(data); return allocateCatalogItemId((nextId) => createOfferCommand("normal", data, async (tx) => { await tx.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 sendCatalogUpdate(); 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 sendCatalogUpdate(); 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 sendCatalogUpdate(); 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: {} }; } await moveOffersCommand(ids, targetPageId); await sendCatalogUpdate(); 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 () => { await reorderOffersCommand(orders); await sendCatalogUpdate(); 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 () => { try { await updateOfferCommand({ id, catalogFields, baseItem }); } catch (error) { return { ok: false as const, error: error instanceof Error ? error.message : "Unable to update catalog item", }; } await sendCatalogUpdate(); 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"); const { patchLocalizedFurniDataEntries } = await import( "@/lib/services/furni-data-i18n" ); let namesUpdated = 0; let descriptionsUpdated = 0; const furniPatches: Array<{ classname: string; itemType: string; name?: string; description?: string; spriteId?: number; createIfMissing?: boolean; }> = []; const localizedEntries: Array<{ classname: string; name?: string; description?: string; }> = []; 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; const englishName = base.publicName?.trim() || base.itemName || ""; furniPatches.push({ classname: base.itemName, itemType: base.type || "s", name: nextName || base.publicName || base.itemName, description: nextDesc, spriteId: base.spriteId, createIfMissing: true, }); localizedEntries.push({ classname: base.itemName, name: englishName, description: nextDesc || undefined, }); } } const furniResult = furniPatches.length > 0 ? await patchFurniEntryNames(furniPatches) : { updated: 0, inserted: 0 }; if (furniResult.updated > 0 || furniResult.inserted > 0) { invalidateFurniDataCache(); } if (localizedEntries.length > 0) { try { await patchLocalizedFurniDataEntries(localizedEntries, true); } catch { // Best-effort: localized files update is non-critical } } await sendCatalogUpdate(); 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, }, }; }); }