From 7bc08459325e4e9cfc651a6d3f8ff36f0cf49001 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 17 Jul 2026 22:21:49 +0200 Subject: [PATCH] Fix catalog items missing due to page_id VARCHAR mismatch. Use raw SQL for counts/loads/creates/moves so Habbo DBs with VARCHAR page_id and no AUTO_INCREMENT still show and persist furni. Co-authored-by: Cursor --- src/actions/catalog-items.ts | 108 +++++++++++++++++------ src/app/admin/catalog/page.tsx | 41 +++------ src/app/api/admin/catalog/items/route.ts | 8 +- src/app/api/admin/catalog/tree/route.ts | 6 +- src/lib/services/catalog-items-loader.ts | 5 +- src/lib/services/catalog-tree.ts | 50 +++++++---- 6 files changed, 139 insertions(+), 79 deletions(-) diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index 0b041f67..2cf94ac2 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -1,9 +1,11 @@ "use server"; import { revalidatePath } from "next/cache"; +import { Prisma } from "@/generated/prisma/client"; import { requirePermission } from "@/lib/admin/guard"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; +import { allocateCatalogItemId } from "@/lib/services/furni-import"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -63,6 +65,44 @@ function pickAllowed( 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 prisma.$executeRaw` + 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; @@ -92,19 +132,17 @@ export async function createCatalogItem(data: { catalogName = base?.publicName || base?.itemName || String(firstId); } } - const created = await prisma.catalogItems.create({ - data: { ...data, catalogName }, - }); + const id = await insertCatalogItemRow({ ...data, catalogName }); await rcon.updateCatalog(); await logStaffActivity({ staffId: staff.id, action: "catalog_item_create", - description: `Created catalog item #${created.id}`, + description: `Created catalog item #${id}`, targetType: "catalog_item", - targetId: created.id, + targetId: id, }); revalidatePath("/admin/catalog"); - return { ok: true as const, data: { id: created.id } }; + return { ok: true as const, data: { id } }; } /** Bulk create with one RCON refresh at the end. */ @@ -145,24 +183,22 @@ export async function bulkCreateCatalogItems({ continue; } try { - await prisma.catalogItems.create({ - data: { - 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: "", - }, + 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 { @@ -207,10 +243,15 @@ export async function moveCatalogItems({ targetPageId: number; }) { await requirePermission(PERMS.CATALOG_EDIT); - await prisma.catalogItems.updateMany({ - where: { id: { in: ids } }, - data: { pageId: targetPageId }, - }); + if (ids.length === 0) { + return { ok: true as const, data: {} }; + } + const pageIdStr = String(targetPageId); + await prisma.$executeRaw` + UPDATE catalog_items + SET page_id = ${pageIdStr} + WHERE id IN (${Prisma.join(ids)}) + `; await rcon.updateCatalog(); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; @@ -244,6 +285,17 @@ export async function updateCatalogItem({ 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 prisma.$executeRaw` + UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id} + `; + delete safeCatalog.pageId; + } + if (Object.keys(safeCatalog).length > 0) { await prisma.catalogItems.update({ where: { id }, diff --git a/src/app/admin/catalog/page.tsx b/src/app/admin/catalog/page.tsx index df1a450e..fb9a79e4 100644 --- a/src/app/admin/catalog/page.tsx +++ b/src/app/admin/catalog/page.tsx @@ -4,6 +4,7 @@ import { Prisma } from "@/generated/prisma/client"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; +import { getCatalogItemCounts } from "@/lib/services/catalog-tree"; import { CatalogActions } from "./catalog-actions"; import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper"; import { CatalogTable } from "./catalog-table"; @@ -238,7 +239,7 @@ export default async function CatalogPagesPage({ orderBy: { orderNum: "asc" }, }); const rootIds = rootPagesRaw.map((p) => p.id); - const [rootChildCounts, rootItemCounts] = await Promise.all([ + const [rootChildCounts, rootItemCountMap] = await Promise.all([ rootIds.length > 0 ? prisma.catalogPages.groupBy({ by: ["parentId"], @@ -246,22 +247,12 @@ export default async function CatalogPagesPage({ _count: true, }) : [], - rootIds.length > 0 - ? prisma.catalogItems - .groupBy({ - by: ["pageId"], - where: { pageId: { in: rootIds } }, - _count: true, - }) - .catch(() => []) - : [], + getCatalogItemCounts(rootIds), ]); const rootChildMap = Object.fromEntries( rootChildCounts.map((c) => [c.parentId, c._count]), ); - const rootItemMap = Object.fromEntries( - rootItemCounts.map((c) => [c.pageId, c._count]), - ); + const rootItemMap = Object.fromEntries(rootItemCountMap); const rootPages: TreeNodeShape[] = rootPagesRaw.map((p) => ({ id: p.id, caption: p.caption, @@ -301,16 +292,16 @@ export default async function CatalogPagesPage({ if (search) { const searchPattern = `%${search}%`; const parentClause = hasParent - ? Prisma.sql`AND cp.parentId = ${parentId}` + ? Prisma.sql`AND cp.parent_id = ${parentId}` : Prisma.empty; const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql` SELECT DISTINCT cp.id FROM catalog_pages cp - LEFT JOIN catalog_items ci ON ci.pageId = cp.id - LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.itemIds, ';', ',')) + LEFT JOIN catalog_items ci ON CAST(ci.page_id AS CHAR) = CAST(cp.id AS CHAR) + LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) WHERE (cp.caption LIKE ${searchPattern} - OR cp.pageLayout LIKE ${searchPattern} - OR ci.catalogName LIKE ${searchPattern} + OR cp.page_layout LIKE ${searchPattern} + OR ci.catalog_name LIKE ${searchPattern} OR ib.public_name LIKE ${searchPattern} OR ib.item_name LIKE ${searchPattern}) ${parentClause} @@ -339,7 +330,7 @@ export default async function CatalogPagesPage({ const parentIds = [ ...new Set(pages.map((p) => p.parentId).filter((id) => id > 0)), ]; - const [childCounts, itemCounts, parentPages] = await Promise.all([ + const [childCounts, itemCountMapRaw, parentPages] = await Promise.all([ pageIds.length > 0 ? prisma.catalogPages.groupBy({ by: ["parentId"], @@ -347,13 +338,7 @@ export default async function CatalogPagesPage({ _count: true, }) : [], - pageIds.length > 0 - ? prisma.catalogItems.groupBy({ - by: ["pageId"], - where: { pageId: { in: pageIds } }, - _count: true, - }) - : [], + getCatalogItemCounts(pageIds), parentIds.length > 0 ? prisma.catalogPages.findMany({ where: { id: { in: parentIds } }, @@ -364,9 +349,7 @@ export default async function CatalogPagesPage({ const childCountMap = Object.fromEntries( childCounts.map((c) => [c.parentId, c._count]), ); - const itemCountMap = Object.fromEntries( - itemCounts.map((c) => [c.pageId, c._count]), - ); + const itemCountMap = Object.fromEntries(itemCountMapRaw); const parentNameMap = Object.fromEntries( parentPages.map((p) => [p.id, p.caption]), ); diff --git a/src/app/api/admin/catalog/items/route.ts b/src/app/api/admin/catalog/items/route.ts index e5d29174..90284617 100644 --- a/src/app/api/admin/catalog/items/route.ts +++ b/src/app/api/admin/catalog/items/route.ts @@ -3,6 +3,12 @@ import { apiError, apiOk } from "@/lib/api-response"; import { PERMS } from "@/lib/permissions"; import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader"; +function jsonSafe(data: T): T { + return JSON.parse( + JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? Number(v) : v)), + ) as T; +} + export const GET = withAdmin( { permission: PERMS.CATALOG_VIEW }, async (request) => { @@ -11,6 +17,6 @@ export const GET = withAdmin( return apiError("Invalid pageId"); } const data = await loadCatalogItemsData(pageId); - return apiOk(data as unknown as Record); + return apiOk(jsonSafe(data) as unknown as Record); }, ); diff --git a/src/app/api/admin/catalog/tree/route.ts b/src/app/api/admin/catalog/tree/route.ts index 9f353bc6..436bc09b 100644 --- a/src/app/api/admin/catalog/tree/route.ts +++ b/src/app/api/admin/catalog/tree/route.ts @@ -6,7 +6,7 @@ import { deletePage, getAncestors, getTreeFlat, - safeItemCounts, + getCatalogItemCounts, toInt, } from "@/lib/services/catalog-tree"; import { rcon } from "@/lib/services/rcon"; @@ -158,7 +158,7 @@ async function getChildren( _count: true, }) : Promise.resolve([]), - safeItemCounts(ids), + getCatalogItemCounts(ids), ]); const childMap = Object.fromEntries( childCounts.map((c) => [toInt(c.parentId), c._count]), @@ -234,7 +234,7 @@ async function searchPages(q: string, isBc: boolean): Promise { _count: true, }) : Promise.resolve([]), - safeItemCounts(ids), + getCatalogItemCounts(ids), ]); const childMap = Object.fromEntries( childCounts.map((c) => [toInt(c.parentId), c._count]), diff --git a/src/lib/services/catalog-items-loader.ts b/src/lib/services/catalog-items-loader.ts index 9846fb67..8c3cb6c9 100644 --- a/src/lib/services/catalog-items-loader.ts +++ b/src/lib/services/catalog-items-loader.ts @@ -135,8 +135,11 @@ export async function loadCatalogItemsData( ): Promise { // Load items via raw query to work around pageId Int vs VARCHAR mismatch const pageIdStr = String(pageId); + // CAST: live Habbo DBs often store page_id as VARCHAR while Prisma maps Int. const rawItems = await prisma.$queryRaw>>` - SELECT * FROM catalog_items WHERE page_id = ${pageIdStr} ORDER BY id ASC + SELECT * FROM catalog_items + WHERE CAST(page_id AS CHAR) = ${pageIdStr} + ORDER BY order_number ASC, id ASC `; const items: RawItem[] = rawItems.map((r: Record) => ({ id: Number(r.id), diff --git a/src/lib/services/catalog-tree.ts b/src/lib/services/catalog-tree.ts index bf02667e..31c85d15 100644 --- a/src/lib/services/catalog-tree.ts +++ b/src/lib/services/catalog-tree.ts @@ -1,3 +1,4 @@ +import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/prisma"; export type { NestedTreeNode, TreeNode } from "@/types/catalog"; @@ -9,27 +10,43 @@ function toInt(value: unknown, fallback = 0): number { return Number.isFinite(n) ? n : fallback; } -async function safeItemCounts( +/** + * Count catalog_items per page via raw SQL. + * Real Habbo DBs often store page_id as VARCHAR; Prisma Int groupBy fails or returns 0. + */ +export async function getCatalogItemCounts( pageIds?: number[], ): Promise> { const map = new Map(); try { if (pageIds && pageIds.length === 0) return map; - const itemCounts = pageIds - ? await prisma.catalogItems.groupBy({ - by: ["pageId"], - where: { pageId: { in: pageIds } }, - _count: true, - }) - : await prisma.catalogItems.groupBy({ - by: ["pageId"], - _count: true, - }); - for (const c of itemCounts) { - map.set(toInt(c.pageId), c._count); + + if (pageIds) { + const idStrs = pageIds.map(String); + const rows = await prisma.$queryRaw< + Array<{ page_id: string | number; cnt: number | bigint }> + >` + SELECT page_id, COUNT(*) as cnt + FROM catalog_items + WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)}) + GROUP BY page_id + `; + for (const r of rows) { + map.set(toInt(r.page_id), toInt(r.cnt)); + } + return map; + } + + const rows = await prisma.$queryRaw< + Array<{ page_id: string | number; cnt: number | bigint }> + >` + SELECT page_id, COUNT(*) as cnt FROM catalog_items GROUP BY page_id + `; + for (const r of rows) { + map.set(toInt(r.page_id), toInt(r.cnt)); } } catch { - // Some DBs store page_id as VARCHAR — ignore counts rather than failing the tree. + // Best-effort — never break the tree for count failures. } return map; } @@ -41,7 +58,7 @@ export async function getTreeFlat(): Promise { const allPages = await prisma.catalogPages.findMany({ orderBy: { orderNum: "asc" }, }); - const itemCountMap = await safeItemCounts(); + const itemCountMap = await getCatalogItemCounts(); const childCountMap = new Map(); for (const p of allPages) { @@ -261,5 +278,4 @@ export async function getAncestors( return ancestors; } -/** Safe item-count helper exported for the tree API route. */ -export { safeItemCounts, toInt }; +export { toInt };