import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { deletePage, getAncestors, getTreeFlat, } from "@/lib/services/catalog-tree"; import { rcon } from "@/lib/services/rcon"; import type { TreeNode } from "@/types/catalog"; type PageRow = { id: number; caption: string; parentId: number; orderNum: number; enabled: string; visible: string; iconImage: number; iconColor: number; pageLayout: string; }; async function getBcTreeFlat(): Promise { const [allPages, itemCounts] = await Promise.all([ prisma.catalogPagesBc.findMany({ orderBy: { orderNum: "asc" } }), prisma.catalogItemsBc.groupBy({ by: ["pageId"], _count: true }), ]); const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c._count])); const childCountMap = new Map(); for (const p of allPages) { childCountMap.set(p.parentId, (childCountMap.get(p.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)!; const page = pageMap.get(pageId); if (!page || page.parentId <= 0) { depthCache.set(pageId, 0); return 0; } const d = computeDepth(page.parentId) + 1; depthCache.set(pageId, d); return d; } return allPages.map((p) => ({ id: p.id, caption: p.caption, parentId: p.parentId, depth: computeDepth(p.id), orderNum: p.orderNum, enabled: String(p.enabled), visible: String(p.visible), iconImage: p.iconImage, iconColor: p.iconColor, pageLayout: p.pageLayout, childCount: childCountMap.get(p.id) ?? 0, itemCount: itemCountMap.get(p.id) ?? 0, })); } function toTreeNode( p: PageRow, childCount: number, itemCount: number, depth = 0, ): TreeNode { return { id: p.id, caption: p.caption, parentId: p.parentId, depth, orderNum: p.orderNum, enabled: String(p.enabled), visible: String(p.visible), iconImage: p.iconImage, iconColor: p.iconColor, pageLayout: p.pageLayout, childCount, itemCount, }; } async function getChildren( parentId: number, isBc: boolean, ): Promise { const parentFilter = parentId === -1 ? { OR: [{ parentId: -1 }, { parentId: 0 }] } : { parentId }; if (isBc) { const pages = await prisma.catalogPagesBc.findMany({ where: parentFilter, orderBy: { orderNum: "asc" }, }); const ids = pages.map((p) => p.id); const [childCounts, itemCounts] = await Promise.all([ ids.length ? prisma.catalogPagesBc.groupBy({ by: ["parentId"], where: { parentId: { in: ids } }, _count: true, }) : [], ids.length ? prisma.catalogItemsBc.groupBy({ by: ["pageId"], where: { pageId: { in: ids } }, _count: true, }) : [], ]); const childMap = Object.fromEntries( childCounts.map((c) => [c.parentId, c._count]), ); const itemMap = Object.fromEntries( itemCounts.map((c) => [c.pageId, c._count]), ); return pages.map((p) => toTreeNode(p, childMap[p.id] ?? 0, itemMap[p.id] ?? 0), ); } const pages = await prisma.catalogPages.findMany({ where: parentFilter, orderBy: { orderNum: "asc" }, }); const ids = pages.map((p) => p.id); const [childCounts, itemCounts] = await Promise.all([ ids.length ? prisma.catalogPages.groupBy({ by: ["parentId"], where: { parentId: { in: ids } }, _count: true, }) : [], ids.length ? prisma.catalogItems.groupBy({ by: ["pageId"], where: { pageId: { in: ids } }, _count: true, }) : [], ]); const childMap = Object.fromEntries( childCounts.map((c) => [c.parentId, c._count]), ); const itemMap = Object.fromEntries( itemCounts.map((c) => [c.pageId, c._count]), ); return pages.map((p) => toTreeNode(p, childMap[p.id] ?? 0, itemMap[p.id] ?? 0), ); } async function searchPages(q: string, isBc: boolean): Promise { const flat = isBc ? await getBcTreeFlat() : await getTreeFlat(); const needle = q.toLowerCase(); return flat.filter( (p) => p.caption.toLowerCase().includes(needle) || String(p.id) === needle, ); } export const GET = withAdmin( { permission: PERMS.CATALOG_VIEW }, async (request) => { const sp = request.nextUrl.searchParams; const isBc = sp.get("catalog") === "bc"; if (sp.get("mode") === "full") { const pages = isBc ? await getBcTreeFlat() : await getTreeFlat(); return apiOk({ pages }); } const pageIdRaw = sp.get("pageId"); if (pageIdRaw) { const id = Number(pageIdRaw); if (!Number.isFinite(id) || id <= 0) return apiError("Invalid pageId"); if (isBc) { const page = await prisma.catalogPagesBc.findUnique({ where: { id } }); if (!page) return apiError("Page not found", 404); return apiOk({ page }); } const page = await prisma.catalogPages.findUnique({ where: { id } }); if (!page) return apiError("Page not found", 404); return apiOk({ page }); } const ancestorsRaw = sp.get("ancestors"); if (ancestorsRaw) { const id = Number(ancestorsRaw); if (!Number.isFinite(id) || id <= 0) return apiError("Invalid id"); if (isBc) { const chain: Array<{ id: number; caption: string }> = []; let currentId = id; for (let i = 0; i < 20; i++) { const page = await prisma.catalogPagesBc.findUnique({ where: { id: currentId }, select: { id: true, caption: true, parentId: true }, }); if (!page) break; chain.unshift({ id: page.id, caption: page.caption }); if (page.parentId <= 0) break; currentId = page.parentId; } return apiOk({ ancestors: chain.filter((a) => a.id !== id), }); } const chain = await getAncestors(id); return apiOk({ ancestors: chain.filter((a) => a.id !== id) }); } const search = sp.get("search"); if (search?.trim()) { const pages = await searchPages(search.trim(), isBc); return apiOk({ pages }); } const parentIdRaw = sp.get("parentId"); if (parentIdRaw !== null) { const parentId = Number(parentIdRaw); if (!Number.isFinite(parentId)) return apiError("Invalid parentId"); const pages = await getChildren(parentId, isBc); return apiOk({ pages }); } return apiOk({ pages: [] }); }, ); export const PATCH = withAdmin( { permission: PERMS.CATALOG_EDIT }, async (request) => { const body = (await request.json()) as { pageId?: number; toggleField?: "toggleEnabled" | "toggleVisible"; catalog?: string; }; const pageId = Number(body.pageId); const field = body.toggleField === "toggleVisible" ? "visible" : "enabled"; if (!Number.isFinite(pageId) || pageId <= 0) { return apiError("Invalid pageId"); } const isBc = body.catalog === "bc"; if (isBc) { const page = await prisma.catalogPagesBc.findUnique({ where: { id: pageId }, select: { enabled: true, visible: true }, }); if (!page) return apiError("Page not found", 404); const current = field === "enabled" ? page.enabled : page.visible; await prisma.catalogPagesBc.update({ where: { id: pageId }, data: { [field]: current === "1" ? "0" : "1" }, }); await rcon.updateCatalog(); return apiOk({}); } const page = await prisma.catalogPages.findUnique({ where: { id: pageId }, select: { enabled: true, visible: true }, }); if (!page) return apiError("Page not found", 404); const current = field === "enabled" ? page.enabled : page.visible; await prisma.catalogPages.update({ where: { id: pageId }, data: { [field]: current === "1" ? "0" : "1" }, }); await rcon.updateCatalog(); return apiOk({}); }, ); export const DELETE = withAdmin( { permission: PERMS.CATALOG_EDIT }, async (request) => { const sp = request.nextUrl.searchParams; const pageId = Number(sp.get("pageId")); const mode = sp.get("mode") === "cascade" ? "cascade" : "reparent"; const isBc = sp.get("catalog") === "bc"; if (!Number.isFinite(pageId) || pageId <= 0) { return apiError("Invalid pageId"); } if (isBc) { const page = await prisma.catalogPagesBc.findUnique({ where: { id: pageId }, select: { parentId: true }, }); if (!page) return apiError("Page not found", 404); if (mode === "reparent") { await prisma.$transaction([ prisma.catalogPagesBc.updateMany({ where: { parentId: pageId }, data: { parentId: page.parentId }, }), prisma.catalogItemsBc.deleteMany({ where: { pageId } }), prisma.catalogPagesBc.delete({ where: { id: pageId } }), ]); } else { // Deep cascade: collect all descendants const toDelete: number[] = [pageId]; const queue: number[] = [pageId]; while (queue.length > 0) { const children = await prisma.catalogPagesBc.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 prisma.$transaction([ prisma.catalogItemsBc.deleteMany({ where: { pageId: { in: toDelete } }, }), prisma.catalogPagesBc.deleteMany({ where: { id: { in: toDelete } }, }), ]); } await rcon.updateCatalog(); return apiOk({}); } await deletePage(pageId, mode); await rcon.updateCatalog(); return apiOk({}); }, );