import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/prisma"; export type { NestedTreeNode, TreeNode } from "@/types/catalog"; import type { NestedTreeNode, TreeNode } from "@/types/catalog"; function toInt(value: unknown, fallback = 0): number { const n = typeof value === "number" ? value : Number(value); return Number.isFinite(n) ? n : fallback; } /** * 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; 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 { // Best-effort — never break the tree for count failures. } return map; } /** * Get the full catalog tree as a flat list with depth info. */ export async function getTreeFlat(): Promise { const allPages = await prisma.catalogPages.findMany({ orderBy: { orderNum: "asc" }, }); const itemCountMap = await getCatalogItemCounts(); const childCountMap = new Map(); for (const p of allPages) { const parentId = toInt(p.parentId, -1); childCountMap.set(parentId, (childCountMap.get(parentId) ?? 0) + 1); } const pageMap = new Map(allPages.map((p) => [p.id, p])); const depthCache = new Map(); function computeDepth(pageId: number): number { if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0; const page = pageMap.get(pageId); const parentId = page ? toInt(page.parentId, -1) : -1; if (!page || parentId <= 0) { depthCache.set(pageId, 0); return 0; } const d = computeDepth(parentId) + 1; depthCache.set(pageId, d); return d; } return allPages.map((p) => { const id = toInt(p.id); const parentId = toInt(p.parentId, -1); return { id, caption: p.caption, parentId, depth: computeDepth(id), orderNum: toInt(p.orderNum), enabled: String(p.enabled ?? "0"), visible: String(p.visible ?? "0"), iconImage: toInt(p.iconImage), iconColor: toInt(p.iconColor), pageLayout: p.pageLayout, childCount: childCountMap.get(id) ?? 0, itemCount: itemCountMap.get(id) ?? 0, }; }); } /** * Build a nested tree structure from flat nodes. */ export function buildNestedTree( flat: TreeNode[], rootParentId = -1, ): NestedTreeNode[] { const childrenMap = new Map(); const nodeMap = new Map(); for (const node of flat) { const nested: NestedTreeNode = { ...node, children: [] }; nodeMap.set(node.id, nested); if (!childrenMap.has(node.parentId)) childrenMap.set(node.parentId, []); childrenMap.get(node.parentId)?.push(nested); } for (const node of nodeMap.values()) { node.children = childrenMap.get(node.id) ?? []; } return childrenMap.get(rootParentId) ?? []; } /** * Create a new catalog page. */ export async function createPage(data: { parentId: number; caption: string; captionSave?: string; pageLayout?: string; iconImage?: number; iconColor?: number; minRank?: number; orderNum?: number; visible?: string | boolean; enabled?: string | boolean; }) { const visible = typeof data.visible === "boolean" ? data.visible ? "1" : "0" : (data.visible ?? "1"); const enabled = typeof data.enabled === "boolean" ? data.enabled ? "1" : "0" : (data.enabled ?? "1"); return prisma.catalogPages.create({ data: { caption: data.caption, parentId: data.parentId, pageLayout: data.pageLayout ?? "default_3x3", captionSave: (data.captionSave ?? data.caption).slice(0, 25), iconColor: data.iconColor ?? 0, iconImage: data.iconImage ?? 0, minRank: data.minRank ?? 1, orderNum: data.orderNum ?? 0, visible, enabled, clubOnly: "0", vipOnly: "0", pageHeadline: "", pageTeaser: "", includes: "", }, }); } /** * Move a page to a new parent. * Validates against circular hierarchy (A → B → C → A). */ export async function movePage( pageId: number, newParentId: number, ): Promise { if (newParentId > 0) { let currentId = newParentId; for (let i = 0; i < 50; i++) { if (currentId === pageId) { throw new Error("Cannot move page: would create a circular hierarchy"); } const parent = await prisma.catalogPages.findUnique({ where: { id: currentId }, select: { parentId: true }, }); if (!parent || parent.parentId <= 0) break; currentId = parent.parentId; } } await prisma.catalogPages.update({ where: { id: pageId }, data: { parentId: newParentId }, }); } /** * Delete catalog_items for the given page ids. * Habbo DBs often store page_id as VARCHAR; Prisma Int deleteMany misses rows. */ async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise { if (pageIds.length === 0) return; const idStrs = pageIds.map(String); await prisma.$executeRaw` DELETE FROM catalog_items WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)}) `; } /** * Delete a page with cascade or reparent mode. */ export async function deletePage( pageId: number, mode: "cascade" | "reparent" = "reparent", ): Promise<{ deletedPages: number; movedChildren: number }> { const page = await prisma.catalogPages.findUnique({ where: { id: pageId }, select: { parentId: true }, }); if (!page) return { deletedPages: 0, movedChildren: 0 }; if (mode === "reparent") { const result = await prisma.catalogPages.updateMany({ where: { parentId: pageId }, data: { parentId: page.parentId }, }); await deleteCatalogItemsByPageIds([pageId]); await prisma.catalogPages.delete({ where: { id: pageId } }); return { deletedPages: 1, movedChildren: result.count }; } const deleted = await cascadeDelete(pageId); return { deletedPages: deleted, movedChildren: 0 }; } async function cascadeDelete(pageId: number): Promise { const toDelete: number[] = [pageId]; const queue: number[] = [pageId]; while (queue.length > 0) { const children = await prisma.catalogPages.findMany({ where: { parentId: { in: queue } }, select: { id: true }, }); queue.length = 0; for (const child of children) { toDelete.push(child.id); queue.push(child.id); } } await deleteCatalogItemsByPageIds(toDelete); for (let i = toDelete.length - 1; i >= 0; i--) { await prisma.catalogPages.delete({ where: { id: toDelete[i] } }); } return toDelete.length; } /** * Get ancestors (breadcrumb) from root to the given page. */ export async function getAncestors( pageId: number, ): Promise> { const ancestors: Array<{ id: number; caption: string }> = []; let currentId = pageId; for (let i = 0; i < 50; i++) { const page = await prisma.catalogPages.findUnique({ where: { id: currentId }, select: { id: true, caption: true, parentId: true }, }); if (!page) break; ancestors.unshift({ id: page.id, caption: page.caption }); if (page.parentId <= 0) break; currentId = page.parentId; } return ancestors; } export { toInt };