Files
EpicNext-Cms/src/lib/services/catalog-tree.ts
T
openhands 56061e41d4
CI / check (push) Failing after 12s
CI / deploy (push) Skipped
CI / release (push) Skipped
refactor: replace Prisma ORM runtime with Drizzle ORM facade
- Replace Prisma client runtime with Drizzle ORM (zero Prisma engine/query engine in production)
- Add Prisma-compatible facade (@/lib/prisma-facade.ts) backed by Drizzle for backwards compatibility
- Runtime queries route through Drizzle ORM; @prisma/client is now devDependency (types only)
- Remove @prisma/adapter-mariadb dependency; delete prisma-pool.ts and types/prisma.ts
- New Drizzle schema layer: src/db/schema.ts (176 tables) and src/lib/db.ts (connection)
- Update README documenting the dual-layer ORM architecture
- Restore src/generated/ gitignore (build artifact for local type generation)
- 0 TypeScript errors, 583 tests passing

The facade intentionally uses `any` types to match the Prisma Client API surface,
allowing existing code to run unmodified while routing queries through Drizzle at runtime.
2026-07-31 14:11:03 +02:00

311 lines
8.1 KiB
TypeScript

import { asc, eq, inArray, sql } from "drizzle-orm";
import { CatalogPages, db } from "@/lib/db";
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<Map<number, number>> {
const map = new Map<number, number>();
try {
if (pageIds && pageIds.length === 0) return map;
if (pageIds) {
const idStrs = pageIds.map(String);
const [rows] = (await db.execute(sql`
SELECT page_id, COUNT(*) as cnt
FROM catalog_items
WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)})
GROUP BY page_id
`)) as unknown as [
{ page_id: string | number; cnt: number | bigint }[],
unknown[],
];
for (const r of rows) {
map.set(toInt(r.page_id), toInt(r.cnt));
}
return map;
}
const [rows] = (await db.execute(sql`
SELECT page_id, COUNT(*) as cnt FROM catalog_items GROUP BY page_id
`)) as unknown as [
{ page_id: string | number; cnt: number | bigint }[],
unknown[],
];
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<TreeNode[]> {
const allPages = await db
.select()
.from(CatalogPages)
.orderBy(asc(CatalogPages.orderNum));
const itemCountMap = await getCatalogItemCounts();
const childCountMap = new Map<number, number>();
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<number, number>();
function computeDepth(pageId: number, visiting = new Set<number>()): number {
if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0;
// Cycle guard: parent chains may loop in messy DBs — stop at 0 instead of overflowing.
if (visiting.has(pageId)) {
depthCache.set(pageId, 0);
return 0;
}
visiting.add(pageId);
const page = pageMap.get(pageId);
const parentId = page ? toInt(page.parentId, -1) : -1;
if (!page || parentId <= 0) {
visiting.delete(pageId);
depthCache.set(pageId, 0);
return 0;
}
const d = computeDepth(parentId, visiting) + 1;
visiting.delete(pageId);
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<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);
}
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 db.insert(CatalogPages).values({
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<void> {
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 db
.select({ parentId: CatalogPages.parentId })
.from(CatalogPages)
.where(eq(CatalogPages.id, currentId))
.limit(1);
if (!parent || parent.parentId <= 0) break;
currentId = parent.parentId;
}
}
await db
.update(CatalogPages)
.set({ parentId: newParentId })
.where(eq(CatalogPages.id, pageId));
}
/**
* 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<void> {
if (pageIds.length === 0) return;
const idStrs = pageIds.map(String);
await db.execute(sql`
DELETE FROM catalog_items
WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)})
`);
}
/**
* 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 db
.select({ parentId: CatalogPages.parentId })
.from(CatalogPages)
.where(eq(CatalogPages.id, pageId))
.limit(1);
if (!page) return { deletedPages: 0, movedChildren: 0 };
if (mode === "reparent") {
const [result] = await db
.update(CatalogPages)
.set({ parentId: page.parentId })
.where(eq(CatalogPages.parentId, pageId));
await deleteCatalogItemsByPageIds([pageId]);
await db.delete(CatalogPages).where(eq(CatalogPages.id, pageId));
return { deletedPages: 1, movedChildren: result.affectedRows };
}
const deleted = await cascadeDelete(pageId);
return { deletedPages: deleted, movedChildren: 0 };
}
async function cascadeDelete(pageId: number): Promise<number> {
const toDelete: number[] = [pageId];
const queue: number[] = [pageId];
while (queue.length > 0) {
const children = await db
.select({ id: CatalogPages.id })
.from(CatalogPages)
.where(inArray(CatalogPages.parentId, queue));
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 db.delete(CatalogPages).where(eq(CatalogPages.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;
for (let i = 0; i < 50; i++) {
const [page] = await db
.select({
id: CatalogPages.id,
caption: CatalogPages.caption,
parentId: CatalogPages.parentId,
})
.from(CatalogPages)
.where(eq(CatalogPages.id, currentId))
.limit(1);
if (!page) break;
ancestors.unshift({ id: page.id, caption: page.caption });
if (page.parentId <= 0) break;
currentId = page.parentId;
}
return ancestors;
}
export { toInt };