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

+88 -88
View File
@@ -2,69 +2,92 @@ import { prisma } from "@/lib/prisma";
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.
* Returns a flat list with depth info for client-side tree building.
* Depth is computed from parentId hierarchy (not stored in DB).
* Get the full catalog tree as a flat list with depth info.
*/
export async function getTreeFlat(): Promise<TreeNode[]> {
const [allPages, itemCounts] = await Promise.all([
prisma.catalogPages.findMany({
orderBy: { orderNum: "asc" },
}),
prisma.catalogItems.groupBy({
by: ["pageId"],
_count: true,
}),
]);
const allPages = await prisma.catalogPages.findMany({
orderBy: { orderNum: "asc" },
});
const itemCountMap = await safeItemCounts();
const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c._count]));
// Count children per page
const childCountMap = new Map<number, number>();
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 depthCache = new Map<number, number>();
function computeDepth(pageId: number): number {
if (depthCache.has(pageId)) return depthCache.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);
return 0;
}
const d = computeDepth(page.parentId) + 1;
const d = computeDepth(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,
}));
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.
*/
import type { NestedTreeNode } from "@/types/catalog";
export function buildNestedTree(
flat: TreeNode[],
rootParentId = -1,
@@ -79,7 +102,6 @@ export function buildNestedTree(
childrenMap.get(node.parentId)?.push(nested);
}
// Attach children
for (const node of nodeMap.values()) {
node.children = childrenMap.get(node.id) ?? [];
}
@@ -99,53 +121,41 @@ export async function createPage(data: {
iconColor?: number;
minRank?: number;
orderNum?: number;
visible?: boolean;
enabled?: boolean;
}): Promise<{ id: number }> {
// Auto-generate captionSave if not provided
const captionSave =
data.captionSave ||
data.caption
.toLowerCase()
.replace(/[^a-z0-9_]/g, "_")
.substring(0, 25);
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");
// Get next orderNum if not provided
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({
return prisma.catalogPages.create({
data: {
parentId: data.parentId,
caption: data.caption,
captionSave,
parentId: data.parentId,
pageLayout: data.pageLayout ?? "default_3x3",
iconImage: data.iconImage ?? 1,
iconColor: data.iconColor ?? 1,
captionSave: (data.captionSave ?? data.caption).slice(0, 25),
iconColor: data.iconColor ?? 0,
iconImage: data.iconImage ?? 0,
minRank: data.minRank ?? 1,
orderNum,
visible: (data.visible ?? true) ? "1" : "0",
enabled: (data.enabled ?? true) ? "1" : "0",
orderNum: data.orderNum ?? 0,
visible,
enabled,
clubOnly: "0",
vipOnly: "0",
pageHeadline: "",
pageTeaser: "",
pageSpecial: "",
pageText1: "",
pageText2: "",
pageTextDetails: "",
pageTextTeaser: "",
includes: "",
},
select: { id: true },
});
return created;
}
/**
@@ -156,7 +166,6 @@ export async function movePage(
pageId: number,
newParentId: number,
): Promise<void> {
// Walk up from newParentId to root — if we hit pageId, it's circular
if (newParentId > 0) {
let currentId = newParentId;
for (let i = 0; i < 50; i++) {
@@ -180,8 +189,6 @@ export async function movePage(
/**
* 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(
pageId: number,
@@ -194,27 +201,22 @@ export async function deletePage(
if (!page) return { deletedPages: 0, movedChildren: 0 };
if (mode === "reparent") {
// Move children to page's parent
const result = await prisma.catalogPages.updateMany({
where: { parentId: pageId },
data: { parentId: page.parentId },
});
// Delete items in this page
await prisma.catalogItems.deleteMany({ where: { pageId } });
// Delete the page
await prisma.catalogPages.delete({ where: { id: pageId } });
return { deletedPages: 1, movedChildren: result.count };
}
// Cascade: delete all descendants
const deleted = await cascadeDelete(pageId);
return { deletedPages: deleted, movedChildren: 0 };
}
async function cascadeDelete(pageId: number): Promise<number> {
// Collect all descendant IDs iteratively to avoid N+1 recursive queries
const toDelete: 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 } } });
// Delete in reverse order (deepest first) to avoid FK issues
for (let i = toDelete.length - 1; i >= 0; i--) {
await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
}
@@ -248,9 +248,7 @@ export async function getAncestors(
): Promise<Array<{ id: number; caption: string }>> {
const ancestors: Array<{ id: number; caption: string }> = [];
let currentId = pageId;
// Safety limit to prevent infinite loops
for (let i = 0; i < 20; i++) {
for (let i = 0; i < 50; i++) {
const page = await prisma.catalogPages.findUnique({
where: { id: currentId },
select: { id: true, caption: true, parentId: true },
@@ -260,6 +258,8 @@ export async function getAncestors(
if (page.parentId <= 0) break;
currentId = page.parentId;
}
return ancestors;
}
/** Safe item-count helper exported for the tree API route. */
export { safeItemCounts, toInt };