Fix Visual Manager empty categories: seed roots, harden tree API.
Local Build and Deploy / deploy (push) Successful in 56s

Seed root tabs from SSR, load the full tree without CLEAR_TREE races, coerce parent ids, and tolerate catalog_items page_id type mismatches so category pages actually appear.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 22:02:53 +02:00
1 parent 385cefd0fa
commit 1d8efefce4
6 files changed
+285 -187

No files matched your search

+49 -40
View File
@@ -232,49 +232,48 @@ export default async function CatalogPagesPage({
prisma.catalogPages.count({ where: { enabled: "1" } }), prisma.catalogPages.count({ where: { enabled: "1" } }),
]); ]);
// ── Tree view data ────────────────────────────────────────────── // ── Root pages (tree view + Visual Manager seed) ────────────────
let rootPages: TreeNodeShape[] = []; const rootPagesRaw = await prisma.catalogPages.findMany({
if (viewMode === "tree") { where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
const rootPagesRaw = await prisma.catalogPages.findMany({ orderBy: { orderNum: "asc" },
where: { parentId: -1 }, });
orderBy: { orderNum: "asc" }, const rootIds = rootPagesRaw.map((p) => p.id);
}); const [rootChildCounts, rootItemCounts] = await Promise.all([
const rootIds = rootPagesRaw.map((p) => p.id); rootIds.length > 0
const [rootChildCounts, rootItemCounts] = await Promise.all([ ? prisma.catalogPages.groupBy({
rootIds.length > 0 by: ["parentId"],
? prisma.catalogPages.groupBy({ where: { parentId: { in: rootIds } },
by: ["parentId"], _count: true,
where: { parentId: { in: rootIds } }, })
_count: true, : [],
}) rootIds.length > 0
: [], ? prisma.catalogItems
rootIds.length > 0 .groupBy({
? prisma.catalogItems.groupBy({
by: ["pageId"], by: ["pageId"],
where: { pageId: { in: rootIds } }, where: { pageId: { in: rootIds } },
_count: true, _count: true,
}) })
: [], .catch(() => [])
]); : [],
const rootChildMap = Object.fromEntries( ]);
rootChildCounts.map((c) => [c.parentId, c._count]), 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(
); rootItemCounts.map((c) => [c.pageId, c._count]),
rootPages = rootPagesRaw.map((p) => ({ );
id: p.id, const rootPages: TreeNodeShape[] = rootPagesRaw.map((p) => ({
caption: p.caption, id: p.id,
parentId: p.parentId, caption: p.caption,
enabled: String(p.enabled), parentId: p.parentId,
visible: String(p.visible), enabled: String(p.enabled),
iconImage: p.iconImage, visible: String(p.visible),
iconColor: p.iconColor, iconImage: p.iconImage,
pageLayout: p.pageLayout, iconColor: p.iconColor,
childCount: rootChildMap[p.id] ?? 0, pageLayout: p.pageLayout,
itemCount: rootItemMap[p.id] ?? 0, childCount: rootChildMap[p.id] ?? 0,
})); itemCount: rootItemMap[p.id] ?? 0,
} }));
// ── Table view data (skip if tree) ──────────────────────────── // ── Table view data (skip if tree) ────────────────────────────
let rows: { let rows: {
@@ -399,7 +398,17 @@ export default async function CatalogPagesPage({
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="normal" /> <CatalogViewToggle currentView={viewMode} catalogType="normal" />
{canEdit && <CatalogManagerDialog />} {canEdit && (
<CatalogManagerDialog
initialRoots={rootPages.map((p) => ({
id: p.id,
caption: p.caption,
iconImage: p.iconImage,
enabled: p.enabled,
orderNum: 0,
}))}
/>
)}
{canEdit && <CatalogActions />} {canEdit && <CatalogActions />}
</div> </div>
</div> </div>
+57 -31
View File
@@ -6,6 +6,8 @@ import {
deletePage, deletePage,
getAncestors, getAncestors,
getTreeFlat, getTreeFlat,
safeItemCounts,
toInt,
} from "@/lib/services/catalog-tree"; } from "@/lib/services/catalog-tree";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import type { TreeNode } from "@/types/catalog"; import type { TreeNode } from "@/types/catalog";
@@ -87,8 +89,9 @@ async function getChildren(
parentId: number, parentId: number,
isBc: boolean, isBc: boolean,
): Promise<TreeNode[]> { ): Promise<TreeNode[]> {
// Roots in Habbo DBs are usually parent_id = -1, sometimes 0.
const parentFilter = const parentFilter =
parentId === -1 parentId <= 0
? { OR: [{ parentId: -1 }, { parentId: 0 }] } ? { OR: [{ parentId: -1 }, { parentId: 0 }] }
: { parentId }; : { parentId };
@@ -97,7 +100,7 @@ async function getChildren(
where: parentFilter, where: parentFilter,
orderBy: { orderNum: "asc" }, orderBy: { orderNum: "asc" },
}); });
const ids = pages.map((p) => p.id); const ids = pages.map((p) => toInt(p.id));
const [childCounts, itemCounts] = await Promise.all([ const [childCounts, itemCounts] = await Promise.all([
ids.length ids.length
? prisma.catalogPagesBc.groupBy({ ? prisma.catalogPagesBc.groupBy({
@@ -105,56 +108,79 @@ async function getChildren(
where: { parentId: { in: ids } }, where: { parentId: { in: ids } },
_count: true, _count: true,
}) })
: [], : Promise.resolve([]),
ids.length ids.length
? prisma.catalogItemsBc.groupBy({ ? prisma.catalogItemsBc
by: ["pageId"], .groupBy({
where: { pageId: { in: ids } }, by: ["pageId"],
_count: true, where: { pageId: { in: ids } },
}) _count: true,
: [], })
.catch(() => [])
: Promise.resolve([]),
]); ]);
const childMap = Object.fromEntries( const childMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, c._count]), childCounts.map((c) => [toInt(c.parentId), c._count]),
); );
const itemMap = Object.fromEntries( const itemMap = Object.fromEntries(
itemCounts.map((c) => [c.pageId, c._count]), itemCounts.map((c) => [toInt(c.pageId), c._count]),
);
return pages.map((p) =>
toTreeNode(p, childMap[p.id] ?? 0, itemMap[p.id] ?? 0),
); );
return pages.map((p) => {
const id = toInt(p.id);
return toTreeNode(
{
id,
caption: p.caption,
parentId: toInt(p.parentId, -1),
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,
},
childMap[id] ?? 0,
itemMap[id] ?? 0,
);
});
} }
const pages = await prisma.catalogPages.findMany({ const pages = await prisma.catalogPages.findMany({
where: parentFilter, where: parentFilter,
orderBy: { orderNum: "asc" }, orderBy: { orderNum: "asc" },
}); });
const ids = pages.map((p) => p.id); const ids = pages.map((p) => toInt(p.id));
const [childCounts, itemCounts] = await Promise.all([ const [childCounts, itemMap] = await Promise.all([
ids.length ids.length
? prisma.catalogPages.groupBy({ ? prisma.catalogPages.groupBy({
by: ["parentId"], by: ["parentId"],
where: { parentId: { in: ids } }, where: { parentId: { in: ids } },
_count: true, _count: true,
}) })
: [], : Promise.resolve([]),
ids.length safeItemCounts(ids),
? prisma.catalogItems.groupBy({
by: ["pageId"],
where: { pageId: { in: ids } },
_count: true,
})
: [],
]); ]);
const childMap = Object.fromEntries( const childMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, c._count]), childCounts.map((c) => [toInt(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),
); );
return pages.map((p) => {
const id = toInt(p.id);
return toTreeNode(
{
id,
caption: p.caption,
parentId: toInt(p.parentId, -1),
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,
},
childMap[id] ?? 0,
itemMap.get(id) ?? 0,
);
});
} }
async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> { async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
@@ -17,10 +17,13 @@ import { CatalogManagerProvider, useCatalogManager } from "./use-catalog-store";
/** /**
* Visual Catalog Manager — matches habbo-next UX (full-viewport editor). * Visual Catalog Manager — matches habbo-next UX (full-viewport editor).
* Uses a portal overlay instead of Base UI Dialog (DialogTitle/Popup break * Portal overlay (not Base UI Dialog) so the tree + editor mount reliably.
* the Radix-era layout that habbo-next still uses).
*/ */
export function CatalogManagerDialog() { export function CatalogManagerDialog({
initialRoots = [],
}: {
initialRoots?: RootTab[];
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -68,6 +71,7 @@ export function CatalogManagerDialog() {
<CatalogManagerInner <CatalogManagerInner
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
initialRoots={initialRoots}
/> />
</CatalogManagerProvider> </CatalogManagerProvider>
</div> </div>
@@ -82,9 +86,11 @@ export function CatalogManagerDialog() {
function CatalogManagerInner({ function CatalogManagerInner({
open, open,
onOpenChange, onOpenChange,
initialRoots,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (v: boolean) => void; onOpenChange: (v: boolean) => void;
initialRoots: RootTab[];
}) { }) {
const { state, dispatch } = useCatalogManager(); const { state, dispatch } = useCatalogManager();
@@ -92,23 +98,23 @@ function CatalogManagerInner({
dispatch({ type: "SET_LOADING_ROOT", loading: true }); dispatch({ type: "SET_LOADING_ROOT", loading: true });
try { try {
const res = await fetch("/api/admin/catalog/tree?parentId=-1"); const res = await fetch("/api/admin/catalog/tree?parentId=-1");
if (!res.ok) throw new Error(); const data = await res.json().catch(() => ({}));
const data = await res.json(); if (!res.ok) throw new Error(data.error || "Failed to load roots");
const pages: RootTab[] = (data.pages || []).map( const pages: RootTab[] = (data.pages || []).map(
(p: Record<string, unknown>) => ({ (p: Record<string, unknown>) => ({
id: p.id as number, id: Number(p.id),
caption: p.caption as string, caption: String(p.caption ?? ""),
iconImage: p.iconImage as number, iconImage: Number(p.iconImage) || 0,
enabled: String(p.enabled), enabled: String(p.enabled ?? "0"),
orderNum: (p.orderNum as number) || 0, orderNum: Number(p.orderNum) || 0,
}), }),
); );
dispatch({ type: "SET_ROOT_PAGES", pages }); dispatch({ type: "SET_ROOT_PAGES", pages });
if ( const activeStillValid =
pages.length > 0 && state.activeTabId !== null &&
(!state.activeTabId || !pages.find((p) => p.id === state.activeTabId)) pages.some((p) => p.id === state.activeTabId);
) { if (pages.length > 0 && !activeStillValid) {
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id }); dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
} }
} catch { } catch {
@@ -117,9 +123,22 @@ function CatalogManagerInner({
} }
}, [dispatch, state.activeTabId]); }, [dispatch, state.activeTabId]);
// biome-ignore lint/correctness/useExhaustiveDependencies: load once when dialog opens // Seed from SSR immediately, then refresh from API.
useEffect(() => { useEffect(() => {
if (open) loadRootPages(); if (!open) return;
if (initialRoots.length > 0 && state.rootPages.length === 0) {
const pages = initialRoots.map((p) => ({
...p,
id: Number(p.id),
enabled: String(p.enabled),
}));
dispatch({ type: "SET_ROOT_PAGES", pages });
if (state.activeTabId === null && pages[0]) {
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
}
}
void loadRootPages();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]); }, [open]);
useEffect(() => { useEffect(() => {
@@ -134,7 +153,6 @@ function CatalogManagerInner({
return; return;
} }
dispatch({ type: "SET_ACTIVE_TAB", id }); dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "CLEAR_TREE" });
}; };
const handleCreateRoot = async () => { const handleCreateRoot = async () => {
@@ -166,7 +184,6 @@ function CatalogManagerInner({
const handleEditTab = (id: number) => { const handleEditTab = (id: number) => {
if (state.activeTabId !== id) { if (state.activeTabId !== id) {
dispatch({ type: "SET_ACTIVE_TAB", id }); dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "CLEAR_TREE" });
} }
if ( if (
state.editorDirty && state.editorDirty &&
@@ -120,26 +120,36 @@ export function SortableTree({
setTreeLoading(true); setTreeLoading(true);
try { try {
const res = await fetch("/api/admin/catalog/tree?mode=full"); const res = await fetch("/api/admin/catalog/tree?mode=full");
if (!res.ok) throw new Error(); const data = await res.json().catch(() => ({}));
const data = await res.json(); if (!res.ok) throw new Error(data.error || "Tree request failed");
const pages: TreeNodeData[] = data.pages || []; const pages: TreeNodeData[] = data.pages || [];
dispatch({ type: "SET_FULL_TREE", nodes: pages }); dispatch({ type: "SET_FULL_TREE", nodes: pages });
// Auto-expand first level under the active root so categories are visible.
if (activeTabId !== null) {
const firstLevel = pages
.filter((p) => Number(p.parentId) === activeTabId)
.map((p) => Number(p.id));
if (firstLevel.length > 0) {
dispatch({ type: "EXPAND_PATH", ids: firstLevel });
}
}
} catch { } catch {
toast.error("Failed to load catalog tree"); toast.error("Failed to load catalog tree");
} finally { } finally {
setTreeLoading(false); setTreeLoading(false);
} }
}, [dispatch]); }, [dispatch, activeTabId]);
// Load full tree when tab changes // Load full tree when tab changes — replace atomically (no CLEAR_TREE race).
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => { useEffect(() => {
if (activeTabId === null) return; if (activeTabId === null) return;
dispatch({ type: "CLEAR_TREE" });
setSearchQuery(""); setSearchQuery("");
setSearchResults(null); setSearchResults(null);
setFilter("all"); setFilter("all");
loadFullTree(); dispatch({ type: "COLLAPSE_ALL" });
void loadFullTree();
}, [activeTabId]); // eslint-disable-line react-hooks/exhaustive-deps }, [activeTabId]); // eslint-disable-line react-hooks/exhaustive-deps
// Debounced search // Debounced search
@@ -198,7 +208,6 @@ export function SortableTree({
for (const cid of childIds) { for (const cid of childIds) {
const node = nodes.get(cid); const node = nodes.get(cid);
if (!node) continue; if (!node) continue;
// Apply filter
if (filter === "enabled" && node.enabled !== "1") continue; if (filter === "enabled" && node.enabled !== "1") continue;
if (filter === "disabled" && node.enabled === "1") continue; if (filter === "disabled" && node.enabled === "1") continue;
const isExpanded = expandedIds.has(cid); const isExpanded = expandedIds.has(cid);
@@ -207,6 +216,20 @@ export function SortableTree({
} }
}; };
traverse(activeTabId, 0); traverse(activeTabId, 0);
// If this root has no children yet, still show the root page itself
// so categories are editable (matches page CatalogTree expectations).
if (result.length === 0) {
const rootNode = nodes.get(activeTabId);
if (rootNode) {
if (
!(filter === "enabled" && rootNode.enabled !== "1") &&
!(filter === "disabled" && rootNode.enabled === "1")
) {
result.push({ ...rootNode, depth: 0, isExpanded: false });
}
}
}
return result; return result;
}, [activeTabId, nodes, childrenMap, expandedIds, searchResults, filter]); }, [activeTabId, nodes, childrenMap, expandedIds, searchResults, filter]);
@@ -142,12 +142,35 @@ function reducer(
case "SET_FULL_TREE": { case "SET_FULL_TREE": {
const nodes = new Map<number, TreeNodeData>(); const nodes = new Map<number, TreeNodeData>();
const childrenMap = new Map<number, number[]>(); const childrenMap = new Map<number, number[]>();
for (const n of action.nodes) { for (const raw of action.nodes) {
nodes.set(n.id, n); const id = Number(raw.id);
const parentId = Number(raw.parentId);
if (!Number.isFinite(id)) continue;
const n: TreeNodeData = {
...raw,
id,
parentId: Number.isFinite(parentId) ? parentId : -1,
enabled: String(raw.enabled ?? "0"),
visible: String(raw.visible ?? "0"),
childCount: Number(raw.childCount) || 0,
itemCount: Number(raw.itemCount) || 0,
iconImage: Number(raw.iconImage) || 0,
iconColor: Number(raw.iconColor) || 0,
orderNum: Number(raw.orderNum) || 0,
};
nodes.set(id, n);
const siblings = childrenMap.get(n.parentId) || []; const siblings = childrenMap.get(n.parentId) || [];
siblings.push(n.id); siblings.push(id);
childrenMap.set(n.parentId, siblings); childrenMap.set(n.parentId, siblings);
} }
// Roots may be parent_id -1 or 0 — mirror both keys so tabs resolve children.
const underNeg1 = childrenMap.get(-1) || [];
const under0 = childrenMap.get(0) || [];
if (underNeg1.length || under0.length) {
const merged = [...new Set([...underNeg1, ...under0])];
childrenMap.set(-1, merged);
childrenMap.set(0, merged);
}
for (const [parentId, ids] of childrenMap) { for (const [parentId, ids] of childrenMap) {
ids.sort( ids.sort(
(a, b) => (a, b) =>
+88 -88
View File
@@ -2,69 +2,92 @@ import { prisma } from "@/lib/prisma";
export type { NestedTreeNode, TreeNode } from "@/types/catalog"; export type { NestedTreeNode, TreeNode } from "@/types/catalog";
import type { 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;
}
async function safeItemCounts(
pageIds?: number[],
): Promise<Map<number, number>> {
const map = new Map<number, number>();
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);
}
} catch {
// Some DBs store page_id as VARCHAR — ignore counts rather than failing the tree.
}
return map;
}
/** /**
* Get the full catalog tree recursively using iterative Prisma queries. * Get the full catalog tree as a flat list with depth info.
* Returns a flat list with depth info for client-side tree building.
* Depth is computed from parentId hierarchy (not stored in DB).
*/ */
export async function getTreeFlat(): Promise<TreeNode[]> { export async function getTreeFlat(): Promise<TreeNode[]> {
const [allPages, itemCounts] = await Promise.all([ const allPages = await prisma.catalogPages.findMany({
prisma.catalogPages.findMany({ orderBy: { orderNum: "asc" },
orderBy: { orderNum: "asc" }, });
}), const itemCountMap = await safeItemCounts();
prisma.catalogItems.groupBy({
by: ["pageId"],
_count: true,
}),
]);
const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c._count]));
// Count children per page
const childCountMap = new Map<number, number>(); const childCountMap = new Map<number, number>();
for (const p of allPages) { for (const p of allPages) {
childCountMap.set(p.parentId, (childCountMap.get(p.parentId) ?? 0) + 1); const parentId = toInt(p.parentId, -1);
childCountMap.set(parentId, (childCountMap.get(parentId) ?? 0) + 1);
} }
// Compute depth from parent hierarchy
const pageMap = new Map(allPages.map((p) => [p.id, p])); const pageMap = new Map(allPages.map((p) => [p.id, p]));
const depthCache = new Map<number, number>(); const depthCache = new Map<number, number>();
function computeDepth(pageId: number): number { function computeDepth(pageId: number): number {
if (depthCache.has(pageId)) return depthCache.get(pageId)!; if (depthCache.has(pageId)) return depthCache.get(pageId)!;
const page = pageMap.get(pageId); const page = pageMap.get(pageId);
if (!page || page.parentId <= 0) { const parentId = page ? toInt(page.parentId, -1) : -1;
if (!page || parentId <= 0) {
depthCache.set(pageId, 0); depthCache.set(pageId, 0);
return 0; return 0;
} }
const d = computeDepth(page.parentId) + 1; const d = computeDepth(parentId) + 1;
depthCache.set(pageId, d); depthCache.set(pageId, d);
return d; return d;
} }
return allPages.map((p) => ({ return allPages.map((p) => {
id: p.id, const id = toInt(p.id);
caption: p.caption, const parentId = toInt(p.parentId, -1);
parentId: p.parentId, return {
depth: computeDepth(p.id), id,
orderNum: p.orderNum, caption: p.caption,
enabled: String(p.enabled), parentId,
visible: String(p.visible), depth: computeDepth(id),
iconImage: p.iconImage, orderNum: toInt(p.orderNum),
iconColor: p.iconColor, enabled: String(p.enabled ?? "0"),
pageLayout: p.pageLayout, visible: String(p.visible ?? "0"),
childCount: childCountMap.get(p.id) ?? 0, iconImage: toInt(p.iconImage),
itemCount: itemCountMap.get(p.id) ?? 0, 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. * Build a nested tree structure from flat nodes.
*/ */
import type { NestedTreeNode } from "@/types/catalog";
export function buildNestedTree( export function buildNestedTree(
flat: TreeNode[], flat: TreeNode[],
rootParentId = -1, rootParentId = -1,
@@ -79,7 +102,6 @@ export function buildNestedTree(
childrenMap.get(node.parentId)?.push(nested); childrenMap.get(node.parentId)?.push(nested);
} }
// Attach children
for (const node of nodeMap.values()) { for (const node of nodeMap.values()) {
node.children = childrenMap.get(node.id) ?? []; node.children = childrenMap.get(node.id) ?? [];
} }
@@ -99,53 +121,41 @@ export async function createPage(data: {
iconColor?: number; iconColor?: number;
minRank?: number; minRank?: number;
orderNum?: number; orderNum?: number;
visible?: boolean; visible?: string | boolean;
enabled?: boolean; enabled?: string | boolean;
}): Promise<{ id: number }> { }) {
// Auto-generate captionSave if not provided const visible =
const captionSave = typeof data.visible === "boolean"
data.captionSave || ? data.visible
data.caption ? "1"
.toLowerCase() : "0"
.replace(/[^a-z0-9_]/g, "_") : (data.visible ?? "1");
.substring(0, 25); const enabled =
typeof data.enabled === "boolean"
? data.enabled
? "1"
: "0"
: (data.enabled ?? "1");
// Get next orderNum if not provided return prisma.catalogPages.create({
let orderNum = data.orderNum;
if (orderNum === undefined) {
const lastSibling = await prisma.catalogPages.findFirst({
where: { parentId: data.parentId },
orderBy: { orderNum: "desc" },
select: { orderNum: true },
});
orderNum = (lastSibling?.orderNum ?? 0) + 1;
}
const created = await prisma.catalogPages.create({
data: { data: {
parentId: data.parentId,
caption: data.caption, caption: data.caption,
captionSave, parentId: data.parentId,
pageLayout: data.pageLayout ?? "default_3x3", pageLayout: data.pageLayout ?? "default_3x3",
iconImage: data.iconImage ?? 1, captionSave: (data.captionSave ?? data.caption).slice(0, 25),
iconColor: data.iconColor ?? 1, iconColor: data.iconColor ?? 0,
iconImage: data.iconImage ?? 0,
minRank: data.minRank ?? 1, minRank: data.minRank ?? 1,
orderNum, orderNum: data.orderNum ?? 0,
visible: (data.visible ?? true) ? "1" : "0", visible,
enabled: (data.enabled ?? true) ? "1" : "0", enabled,
clubOnly: "0",
vipOnly: "0",
pageHeadline: "", pageHeadline: "",
pageTeaser: "", pageTeaser: "",
pageSpecial: "",
pageText1: "",
pageText2: "",
pageTextDetails: "",
pageTextTeaser: "",
includes: "", includes: "",
}, },
select: { id: true },
}); });
return created;
} }
/** /**
@@ -156,7 +166,6 @@ export async function movePage(
pageId: number, pageId: number,
newParentId: number, newParentId: number,
): Promise<void> { ): Promise<void> {
// Walk up from newParentId to root — if we hit pageId, it's circular
if (newParentId > 0) { if (newParentId > 0) {
let currentId = newParentId; let currentId = newParentId;
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
@@ -180,8 +189,6 @@ export async function movePage(
/** /**
* Delete a page with cascade or reparent mode. * Delete a page with cascade or reparent mode.
* - cascade: deletes all children and their items
* - reparent: moves children to the deleted page's parent
*/ */
export async function deletePage( export async function deletePage(
pageId: number, pageId: number,
@@ -194,27 +201,22 @@ export async function deletePage(
if (!page) return { deletedPages: 0, movedChildren: 0 }; if (!page) return { deletedPages: 0, movedChildren: 0 };
if (mode === "reparent") { if (mode === "reparent") {
// Move children to page's parent
const result = await prisma.catalogPages.updateMany({ const result = await prisma.catalogPages.updateMany({
where: { parentId: pageId }, where: { parentId: pageId },
data: { parentId: page.parentId }, data: { parentId: page.parentId },
}); });
// Delete items in this page
await prisma.catalogItems.deleteMany({ where: { pageId } }); await prisma.catalogItems.deleteMany({ where: { pageId } });
// Delete the page
await prisma.catalogPages.delete({ where: { id: pageId } }); await prisma.catalogPages.delete({ where: { id: pageId } });
return { deletedPages: 1, movedChildren: result.count }; return { deletedPages: 1, movedChildren: result.count };
} }
// Cascade: delete all descendants
const deleted = await cascadeDelete(pageId); const deleted = await cascadeDelete(pageId);
return { deletedPages: deleted, movedChildren: 0 }; return { deletedPages: deleted, movedChildren: 0 };
} }
async function cascadeDelete(pageId: number): Promise<number> { async function cascadeDelete(pageId: number): Promise<number> {
// Collect all descendant IDs iteratively to avoid N+1 recursive queries
const toDelete: number[] = [pageId]; const toDelete: number[] = [pageId];
const queue: number[] = [pageId]; const queue: number[] = [pageId];
@@ -230,9 +232,7 @@ async function cascadeDelete(pageId: number): Promise<number> {
} }
} }
// Delete all items and pages in bulk (children first, then parents)
await prisma.catalogItems.deleteMany({ where: { pageId: { in: toDelete } } }); await prisma.catalogItems.deleteMany({ where: { pageId: { in: toDelete } } });
// Delete in reverse order (deepest first) to avoid FK issues
for (let i = toDelete.length - 1; i >= 0; i--) { for (let i = toDelete.length - 1; i >= 0; i--) {
await prisma.catalogPages.delete({ where: { id: toDelete[i] } }); await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
} }
@@ -248,9 +248,7 @@ export async function getAncestors(
): Promise<Array<{ id: number; caption: string }>> { ): Promise<Array<{ id: number; caption: string }>> {
const ancestors: Array<{ id: number; caption: string }> = []; const ancestors: Array<{ id: number; caption: string }> = [];
let currentId = pageId; let currentId = pageId;
for (let i = 0; i < 50; i++) {
// Safety limit to prevent infinite loops
for (let i = 0; i < 20; i++) {
const page = await prisma.catalogPages.findUnique({ const page = await prisma.catalogPages.findUnique({
where: { id: currentId }, where: { id: currentId },
select: { id: true, caption: true, parentId: true }, select: { id: true, caption: true, parentId: true },
@@ -260,6 +258,8 @@ export async function getAncestors(
if (page.parentId <= 0) break; if (page.parentId <= 0) break;
currentId = page.parentId; currentId = page.parentId;
} }
return ancestors; return ancestors;
} }
/** Safe item-count helper exported for the tree API route. */
export { safeItemCounts, toInt };