Fix catalog items missing due to page_id VARCHAR mismatch.
Local Build and Deploy / deploy (push) Successful in 56s

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 <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 22:21:49 +02:00
1 parent 33a8a19820
commit 7bc0845932
6 files changed
+139 -79

No files matched your search

+80 -28
View File
@@ -1,9 +1,11 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { Prisma } from "@/generated/prisma/client";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -63,6 +65,44 @@ function pickAllowed(
return out; 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<number> {
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: { export async function createCatalogItem(data: {
pageId: number; pageId: number;
itemIds: string; itemIds: string;
@@ -92,19 +132,17 @@ export async function createCatalogItem(data: {
catalogName = base?.publicName || base?.itemName || String(firstId); catalogName = base?.publicName || base?.itemName || String(firstId);
} }
} }
const created = await prisma.catalogItems.create({ const id = await insertCatalogItemRow({ ...data, catalogName });
data: { ...data, catalogName },
});
await rcon.updateCatalog(); await rcon.updateCatalog();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "catalog_item_create", action: "catalog_item_create",
description: `Created catalog item #${created.id}`, description: `Created catalog item #${id}`,
targetType: "catalog_item", targetType: "catalog_item",
targetId: created.id, targetId: id,
}); });
revalidatePath("/admin/catalog"); 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. */ /** Bulk create with one RCON refresh at the end. */
@@ -145,24 +183,22 @@ export async function bulkCreateCatalogItems({
continue; continue;
} }
try { try {
await prisma.catalogItems.create({ await insertCatalogItemRow({
data: { pageId,
pageId, itemIds: String(row.baseId),
itemIds: String(row.baseId), catalogName: base.publicName || base.itemName || String(row.baseId),
catalogName: base.publicName || base.itemName || String(row.baseId), costCredits: row.credits ?? 0,
costCredits: row.credits ?? 0, costPoints: row.points ?? 0,
costPoints: row.points ?? 0, pointsType: row.pointsType ?? 0,
pointsType: row.pointsType ?? 0, amount: 1,
amount: 1, limitedSells: 0,
limitedSells: 0, limitedStack: 0,
limitedStack: 0, orderNumber: 1,
orderNumber: 1, offerId: -1,
offerId: -1, songId: 0,
songId: 0, haveOffer: "1",
haveOffer: "1", clubOnly: "0",
clubOnly: "0", extradata: "",
extradata: "",
},
}); });
created++; created++;
} catch { } catch {
@@ -207,10 +243,15 @@ export async function moveCatalogItems({
targetPageId: number; targetPageId: number;
}) { }) {
await requirePermission(PERMS.CATALOG_EDIT); await requirePermission(PERMS.CATALOG_EDIT);
await prisma.catalogItems.updateMany({ if (ids.length === 0) {
where: { id: { in: ids } }, return { ok: true as const, data: {} };
data: { pageId: targetPageId }, }
}); const pageIdStr = String(targetPageId);
await prisma.$executeRaw`
UPDATE catalog_items
SET page_id = ${pageIdStr}
WHERE id IN (${Prisma.join(ids)})
`;
await rcon.updateCatalog(); await rcon.updateCatalog();
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
return { ok: true as const, data: {} }; return { ok: true as const, data: {} };
@@ -244,6 +285,17 @@ export async function updateCatalogItem({
if (Object.keys(safeCatalog).length === 0 && !baseItem) { if (Object.keys(safeCatalog).length === 0 && !baseItem) {
return { ok: false as const, error: "No valid fields to update" }; 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) { if (Object.keys(safeCatalog).length > 0) {
await prisma.catalogItems.update({ await prisma.catalogItems.update({
where: { id }, where: { id },
+12 -29
View File
@@ -4,6 +4,7 @@ import { Prisma } from "@/generated/prisma/client";
import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getCatalogItemCounts } from "@/lib/services/catalog-tree";
import { CatalogActions } from "./catalog-actions"; import { CatalogActions } from "./catalog-actions";
import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper"; import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper";
import { CatalogTable } from "./catalog-table"; import { CatalogTable } from "./catalog-table";
@@ -238,7 +239,7 @@ export default async function CatalogPagesPage({
orderBy: { orderNum: "asc" }, orderBy: { orderNum: "asc" },
}); });
const rootIds = rootPagesRaw.map((p) => p.id); const rootIds = rootPagesRaw.map((p) => p.id);
const [rootChildCounts, rootItemCounts] = await Promise.all([ const [rootChildCounts, rootItemCountMap] = await Promise.all([
rootIds.length > 0 rootIds.length > 0
? prisma.catalogPages.groupBy({ ? prisma.catalogPages.groupBy({
by: ["parentId"], by: ["parentId"],
@@ -246,22 +247,12 @@ export default async function CatalogPagesPage({
_count: true, _count: true,
}) })
: [], : [],
rootIds.length > 0 getCatalogItemCounts(rootIds),
? prisma.catalogItems
.groupBy({
by: ["pageId"],
where: { pageId: { in: rootIds } },
_count: true,
})
.catch(() => [])
: [],
]); ]);
const rootChildMap = Object.fromEntries( const rootChildMap = Object.fromEntries(
rootChildCounts.map((c) => [c.parentId, c._count]), rootChildCounts.map((c) => [c.parentId, c._count]),
); );
const rootItemMap = Object.fromEntries( const rootItemMap = Object.fromEntries(rootItemCountMap);
rootItemCounts.map((c) => [c.pageId, c._count]),
);
const rootPages: TreeNodeShape[] = rootPagesRaw.map((p) => ({ const rootPages: TreeNodeShape[] = rootPagesRaw.map((p) => ({
id: p.id, id: p.id,
caption: p.caption, caption: p.caption,
@@ -301,16 +292,16 @@ export default async function CatalogPagesPage({
if (search) { if (search) {
const searchPattern = `%${search}%`; const searchPattern = `%${search}%`;
const parentClause = hasParent const parentClause = hasParent
? Prisma.sql`AND cp.parentId = ${parentId}` ? Prisma.sql`AND cp.parent_id = ${parentId}`
: Prisma.empty; : Prisma.empty;
const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql` const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql`
SELECT DISTINCT cp.id FROM catalog_pages cp SELECT DISTINCT cp.id FROM catalog_pages cp
LEFT JOIN catalog_items ci ON ci.pageId = cp.id 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.itemIds, ';', ',')) LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ','))
WHERE (cp.caption LIKE ${searchPattern} WHERE (cp.caption LIKE ${searchPattern}
OR cp.pageLayout LIKE ${searchPattern} OR cp.page_layout LIKE ${searchPattern}
OR ci.catalogName LIKE ${searchPattern} OR ci.catalog_name LIKE ${searchPattern}
OR ib.public_name LIKE ${searchPattern} OR ib.public_name LIKE ${searchPattern}
OR ib.item_name LIKE ${searchPattern}) OR ib.item_name LIKE ${searchPattern})
${parentClause} ${parentClause}
@@ -339,7 +330,7 @@ export default async function CatalogPagesPage({
const parentIds = [ const parentIds = [
...new Set(pages.map((p) => p.parentId).filter((id) => id > 0)), ...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 pageIds.length > 0
? prisma.catalogPages.groupBy({ ? prisma.catalogPages.groupBy({
by: ["parentId"], by: ["parentId"],
@@ -347,13 +338,7 @@ export default async function CatalogPagesPage({
_count: true, _count: true,
}) })
: [], : [],
pageIds.length > 0 getCatalogItemCounts(pageIds),
? prisma.catalogItems.groupBy({
by: ["pageId"],
where: { pageId: { in: pageIds } },
_count: true,
})
: [],
parentIds.length > 0 parentIds.length > 0
? prisma.catalogPages.findMany({ ? prisma.catalogPages.findMany({
where: { id: { in: parentIds } }, where: { id: { in: parentIds } },
@@ -364,9 +349,7 @@ export default async function CatalogPagesPage({
const childCountMap = Object.fromEntries( const childCountMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, c._count]), childCounts.map((c) => [c.parentId, c._count]),
); );
const itemCountMap = Object.fromEntries( const itemCountMap = Object.fromEntries(itemCountMapRaw);
itemCounts.map((c) => [c.pageId, c._count]),
);
const parentNameMap = Object.fromEntries( const parentNameMap = Object.fromEntries(
parentPages.map((p) => [p.id, p.caption]), parentPages.map((p) => [p.id, p.caption]),
); );
+7 -1
View File
@@ -3,6 +3,12 @@ import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader"; import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
function jsonSafe<T>(data: T): T {
return JSON.parse(
JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? Number(v) : v)),
) as T;
}
export const GET = withAdmin( export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW }, { permission: PERMS.CATALOG_VIEW },
async (request) => { async (request) => {
@@ -11,6 +17,6 @@ export const GET = withAdmin(
return apiError("Invalid pageId"); return apiError("Invalid pageId");
} }
const data = await loadCatalogItemsData(pageId); const data = await loadCatalogItemsData(pageId);
return apiOk(data as unknown as Record<string, unknown>); return apiOk(jsonSafe(data) as unknown as Record<string, unknown>);
}, },
); );
+3 -3
View File
@@ -6,7 +6,7 @@ import {
deletePage, deletePage,
getAncestors, getAncestors,
getTreeFlat, getTreeFlat,
safeItemCounts, getCatalogItemCounts,
toInt, toInt,
} from "@/lib/services/catalog-tree"; } from "@/lib/services/catalog-tree";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
@@ -158,7 +158,7 @@ async function getChildren(
_count: true, _count: true,
}) })
: Promise.resolve([]), : Promise.resolve([]),
safeItemCounts(ids), getCatalogItemCounts(ids),
]); ]);
const childMap = Object.fromEntries( const childMap = Object.fromEntries(
childCounts.map((c) => [toInt(c.parentId), c._count]), childCounts.map((c) => [toInt(c.parentId), c._count]),
@@ -234,7 +234,7 @@ async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
_count: true, _count: true,
}) })
: Promise.resolve([]), : Promise.resolve([]),
safeItemCounts(ids), getCatalogItemCounts(ids),
]); ]);
const childMap = Object.fromEntries( const childMap = Object.fromEntries(
childCounts.map((c) => [toInt(c.parentId), c._count]), childCounts.map((c) => [toInt(c.parentId), c._count]),
+4 -1
View File
@@ -135,8 +135,11 @@ export async function loadCatalogItemsData(
): Promise<CatalogItemsData> { ): Promise<CatalogItemsData> {
// Load items via raw query to work around pageId Int vs VARCHAR mismatch // Load items via raw query to work around pageId Int vs VARCHAR mismatch
const pageIdStr = String(pageId); const pageIdStr = String(pageId);
// CAST: live Habbo DBs often store page_id as VARCHAR while Prisma maps Int.
const rawItems = await prisma.$queryRaw<Array<Record<string, unknown>>>` const rawItems = await prisma.$queryRaw<Array<Record<string, unknown>>>`
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<string, unknown>) => ({ const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
id: Number(r.id), id: Number(r.id),
+33 -17
View File
@@ -1,3 +1,4 @@
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export type { NestedTreeNode, TreeNode } from "@/types/catalog"; export type { NestedTreeNode, TreeNode } from "@/types/catalog";
@@ -9,27 +10,43 @@ function toInt(value: unknown, fallback = 0): number {
return Number.isFinite(n) ? n : fallback; 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[], pageIds?: number[],
): Promise<Map<number, number>> { ): Promise<Map<number, number>> {
const map = new Map<number, number>(); const map = new Map<number, number>();
try { try {
if (pageIds && pageIds.length === 0) return map; if (pageIds && pageIds.length === 0) return map;
const itemCounts = pageIds
? await prisma.catalogItems.groupBy({ if (pageIds) {
by: ["pageId"], const idStrs = pageIds.map(String);
where: { pageId: { in: pageIds } }, const rows = await prisma.$queryRaw<
_count: true, Array<{ page_id: string | number; cnt: number | bigint }>
}) >`
: await prisma.catalogItems.groupBy({ SELECT page_id, COUNT(*) as cnt
by: ["pageId"], FROM catalog_items
_count: true, WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)})
}); GROUP BY page_id
for (const c of itemCounts) { `;
map.set(toInt(c.pageId), c._count); 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 { } 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; return map;
} }
@@ -41,7 +58,7 @@ export async function getTreeFlat(): Promise<TreeNode[]> {
const allPages = await prisma.catalogPages.findMany({ const allPages = await prisma.catalogPages.findMany({
orderBy: { orderNum: "asc" }, orderBy: { orderNum: "asc" },
}); });
const itemCountMap = await safeItemCounts(); const itemCountMap = await getCatalogItemCounts();
const childCountMap = new Map<number, number>(); const childCountMap = new Map<number, number>();
for (const p of allPages) { for (const p of allPages) {
@@ -261,5 +278,4 @@ export async function getAncestors(
return ancestors; return ancestors;
} }
/** Safe item-count helper exported for the tree API route. */ export { toInt };
export { safeItemCounts, toInt };