refactor(catalog): unify commands and embed guarded catalog workspace
CI / check (push) Successful in 1m16s
CI / deploy (push) Successful in 28s

This commit is contained in:
Simo committed 2026-09-06 19:40:52 +02:00
1 parent 16f35568f5
commit 193b6686a9
57 files changed
+3268 -728

No files matched your search

@@ -0,0 +1,49 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs"));
const search = vi.hoisted(() => vi.fn());
const guard = vi.hoisted(() => vi.fn());
vi.mock("@/features/catalog/server/search", () => ({ searchCatalog: search }));
vi.mock("@/features/catalog/server/errors", () => ({
catalogFailure: () => ({ message: "Invalid search", status: 400 }),
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (
options: unknown,
handler: (request: NextRequest) => Promise<Response>,
) => {
guard(options);
return handler;
},
}));
import { PERMS } from "@/lib/permissions";
import { GET } from "./route";
beforeEach(() => {
search.mockReset();
});
describe("catalog search route", () => {
it("registers the catalog read permission", () => {
expect(guard).toHaveBeenCalledWith({ permission: PERMS.CATALOG_VIEW });
});
it("forwards normal or BC without widening scope and returns results", async () => {
search.mockResolvedValue([]);
const response = await GET(
new NextRequest(
"https://cms.test/api/admin/catalog/search?catalog=bc&q=chair",
),
);
expect(search).toHaveBeenCalledWith("bc", "chair");
expect(await response.json()).toEqual({ ok: true, results: [] });
});
it("returns a validation error without a success payload", async () => {
search.mockRejectedValue(Error("Invalid search"));
const response = await GET(
new NextRequest("https://cms.test/api/admin/catalog/search?q=a"),
);
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: "Invalid search" });
});
});
+22
View File
@@ -0,0 +1,22 @@
import { catalogFailure } from "@/features/catalog/server/errors";
import { searchCatalog } from "@/features/catalog/server/search";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW },
async (request) => {
const params = request.nextUrl.searchParams;
try {
return apiOk({
results: await searchCatalog(
params.get("catalog") ?? "normal",
params.get("q") ?? "",
),
});
} catch (error) {
const failure = catalogFailure(error);
return apiError(failure.message, failure.status);
}
},
);
+18
View File
@@ -0,0 +1,18 @@
import {
readCatalogHotelStatus,
sendCatalogUpdate,
} from "@/features/catalog/server/sync-status";
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { catalogExportStatus } from "@/lib/services/catalog-git-queue";
export const GET = withAdmin({ permission: PERMS.CATALOG_VIEW }, async () => {
const [hotel, git] = await Promise.all([
readCatalogHotelStatus(),
catalogExportStatus(),
]);
return apiOk({ hotel, git });
});
export const POST = withAdmin({ permission: PERMS.CATALOG_EDIT }, async () =>
apiOk({ hotel: await sendCatalogUpdate() }),
);
+27 -87
View File
@@ -1,16 +1,20 @@
import { asc, count, eq, inArray, like, or } from "drizzle-orm";
import { catalogFailure } from "@/features/catalog/server/errors";
import {
deletePageCommand,
togglePageCommand,
} from "@/features/catalog/server/page-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { CatalogItemsBc, CatalogPages, CatalogPagesBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import {
deletePage,
getAncestors,
getCatalogItemCounts,
getTreeFlat,
toInt,
} from "@/lib/services/catalog-tree";
import { rcon } from "@/lib/services/rcon";
import type { TreeNode } from "@/types/catalog";
type PageRow = {
@@ -40,7 +44,7 @@ async function getBcTreeFlat(): Promise<TreeNode[]> {
enabled: CatalogPagesBc.enabled,
})
.from(CatalogPagesBc)
.orderBy(asc(CatalogPagesBc.orderNum)),
.orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id)),
db
.select({
pageId: CatalogItemsBc.pageId,
@@ -56,14 +60,16 @@ async function getBcTreeFlat(): Promise<TreeNode[]> {
}
const pageMap = new Map(allPages.map((p) => [p.id, p]));
const depthCache = new Map<number, number>();
function computeDepth(pageId: number): number {
function computeDepth(pageId: number, visiting = new Set<number>()): number {
if (visiting.has(pageId)) return 0;
visiting.add(pageId);
if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0;
const page = pageMap.get(pageId);
if (!page || page.parentId <= 0) {
depthCache.set(pageId, 0);
return 0;
}
const d = computeDepth(page.parentId) + 1;
const d = computeDepth(page.parentId, visiting) + 1;
depthCache.set(pageId, d);
return d;
}
@@ -134,7 +140,7 @@ async function getChildren(
})
.from(CatalogPagesBc)
.where(parentFilterBc)
.orderBy(asc(CatalogPagesBc.orderNum));
.orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id));
const ids = pages.map((p) => toInt(p.id));
const [childCounts, itemCounts] = await Promise.all([
ids.length
@@ -199,7 +205,7 @@ async function getChildren(
})
.from(CatalogPages)
.where(parentFilter)
.orderBy(asc(CatalogPages.orderNum));
.orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id));
const ids = pages.map((p) => toInt(p.id));
const [childCounts, itemMap] = await Promise.all([
ids.length
@@ -259,7 +265,7 @@ async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
})
.from(CatalogPagesBc)
.where(or(...conditions))
.orderBy(asc(CatalogPagesBc.orderNum))
.orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id))
.limit(50);
return pages.map((p) =>
toTreeNode(
@@ -296,7 +302,7 @@ async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
})
.from(CatalogPages)
.where(or(...conditions))
.orderBy(asc(CatalogPages.orderNum))
.orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id))
.limit(50);
const ids = pages.map((p) => toInt(p.id));
const [childCounts, itemMap] = await Promise.all([
@@ -430,39 +436,13 @@ export const PATCH = withAdmin(
return apiError("Invalid pageId");
}
const isBc = body.catalog === "bc";
if (isBc) {
const [page] = await db
.select({
enabled: CatalogPagesBc.enabled,
visible: CatalogPagesBc.visible,
})
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, pageId))
.limit(1);
if (!page) return apiError("Page not found", 404);
const current = field === "enabled" ? page.enabled : page.visible;
await db
.update(CatalogPagesBc)
.set({ [field]: current === "1" ? "0" : "1" })
.where(eq(CatalogPagesBc.id, pageId));
await rcon.updateCatalog();
return apiOk({});
try {
await togglePageCommand(isBc ? "bc" : "normal", pageId, field);
} catch (error) {
const failure = catalogFailure(error);
return apiError(failure.message, failure.status);
}
const [page] = await db
.select({
enabled: CatalogPages.enabled,
visible: CatalogPages.visible,
})
.from(CatalogPages)
.where(eq(CatalogPages.id, pageId))
.limit(1);
if (!page) return apiError("Page not found", 404);
const current = field === "enabled" ? page.enabled : page.visible;
await db
.update(CatalogPages)
.set({ [field]: current === "1" ? "0" : "1" })
.where(eq(CatalogPages.id, pageId));
await rcon.updateCatalog();
await sendCatalogUpdate();
return apiOk({});
},
);
@@ -477,53 +457,13 @@ export const DELETE = withAdmin(
if (!Number.isFinite(pageId) || pageId <= 0) {
return apiError("Invalid pageId");
}
if (isBc) {
const [page] = await db
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, pageId))
.limit(1);
if (!page) return apiError("Page not found", 404);
if (mode === "reparent") {
await db.transaction(async (tx) => {
await tx
.update(CatalogPagesBc)
.set({ parentId: page.parentId })
.where(eq(CatalogPagesBc.parentId, pageId));
await tx
.delete(CatalogItemsBc)
.where(eq(CatalogItemsBc.pageId, pageId));
await tx.delete(CatalogPagesBc).where(eq(CatalogPagesBc.id, pageId));
});
} else {
// Deep cascade: collect all descendants
const toDelete: number[] = [pageId];
const queue: number[] = [pageId];
while (queue.length > 0) {
const children = await db
.select({ id: CatalogPagesBc.id })
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, queue));
queue.length = 0;
for (const child of children) {
toDelete.push(child.id);
queue.push(child.id);
}
}
await db.transaction(async (tx) => {
await tx
.delete(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, toDelete));
await tx
.delete(CatalogPagesBc)
.where(inArray(CatalogPagesBc.id, toDelete));
});
}
await rcon.updateCatalog();
return apiOk({});
try {
await deletePageCommand(isBc ? "bc" : "normal", pageId, mode);
} catch (error) {
const failure = catalogFailure(error);
return apiError(failure.message, failure.status);
}
await deletePage(pageId, mode);
await rcon.updateCatalog();
await sendCatalogUpdate();
return apiOk({});
},
);