258 lines
7.4 KiB
TypeScript
258 lines
7.4 KiB
TypeScript
import { prisma } from "@/lib/prisma";
|
|
|
|
export type { NestedTreeNode, TreeNode } from "@/types/catalog";
|
|
|
|
import type { TreeNode } from "@/types/catalog";
|
|
|
|
/**
|
|
* 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).
|
|
*/
|
|
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 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);
|
|
}
|
|
|
|
// 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) {
|
|
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,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Build a nested tree structure from flat nodes.
|
|
*/
|
|
import type { NestedTreeNode } from "@/types/catalog";
|
|
|
|
export function buildNestedTree(flat: TreeNode[], rootParentId = -1): NestedTreeNode[] {
|
|
const childrenMap = new Map<number, NestedTreeNode[]>();
|
|
const nodeMap = new Map<number, NestedTreeNode>();
|
|
|
|
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);
|
|
}
|
|
|
|
// Attach children
|
|
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?: 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);
|
|
|
|
// 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({
|
|
data: {
|
|
parentId: data.parentId,
|
|
caption: data.caption,
|
|
captionSave,
|
|
pageLayout: data.pageLayout ?? "default_3x3",
|
|
iconImage: data.iconImage ?? 1,
|
|
iconColor: data.iconColor ?? 1,
|
|
minRank: data.minRank ?? 1,
|
|
orderNum,
|
|
visible: (data.visible ?? true) ? "1" : "0",
|
|
enabled: (data.enabled ?? true) ? "1" : "0",
|
|
pageHeadline: "",
|
|
pageTeaser: "",
|
|
pageSpecial: "",
|
|
pageText1: "",
|
|
pageText2: "",
|
|
pageTextDetails: "",
|
|
pageTextTeaser: "",
|
|
includes: "",
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
return created;
|
|
}
|
|
|
|
/**
|
|
* Move a page to a new parent.
|
|
* Validates against circular hierarchy (A → B → C → A).
|
|
*/
|
|
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++) {
|
|
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 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,
|
|
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") {
|
|
// 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];
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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] } });
|
|
}
|
|
|
|
return toDelete.length;
|
|
}
|
|
|
|
/**
|
|
* Get ancestors (breadcrumb) from root to the given page.
|
|
*/
|
|
export async function getAncestors(pageId: number): 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++) {
|
|
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;
|
|
}
|