diff --git a/integration/database.test.ts b/integration/database.test.ts index ed440d8f..4671d53e 100644 --- a/integration/database.test.ts +++ b/integration/database.test.ts @@ -35,7 +35,13 @@ vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs")); vi.mock("next-intl/server", () => ({ getTranslations: async () => (key: string) => key, })); -vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +// The data cache only exists inside a Next render, so both entry points degrade +// to the real work underneath them instead of being stubbed out. +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), + revalidateTag: vi.fn(), + unstable_cache: (fn: unknown) => fn, +})); vi.mock("next/navigation", () => ({ redirect: (url: string) => { throw Error(`Unexpected integration redirect: ${url}`); diff --git a/src/actions/catalog-bulk.ts b/src/actions/catalog-bulk.ts index e0fc5cb7..318232ec 100644 --- a/src/actions/catalog-bulk.ts +++ b/src/actions/catalog-bulk.ts @@ -1,8 +1,10 @@ "use server"; import { revalidatePath } from "next/cache"; import type { BulkOfferInput } from "@/features/catalog/domain/bulk-offers"; +import { CatalogInputError } from "@/features/catalog/domain/hierarchy"; import { applyBulkOffersCommand, + type BulkCatalogKind, listBulkOfferDestinationsCommand, previewBulkOffersCommand, undoBulkOffersCommand, @@ -13,10 +15,56 @@ import { requirePermission } from "@/lib/admin/guard"; import { logger } from "@/lib/logger"; import { PERMS } from "@/lib/permissions"; import { withCatalogExport } from "@/lib/services/catalog-git-queue"; -export async function previewBulkOffers(input: BulkOfferInput) { +import { logStaffActivity } from "@/lib/services/staff-activity"; + +/** The catalog is a server boundary, not a client choice. */ +function bulkKind(catalog: unknown): BulkCatalogKind { + if (catalog === "bc") return "bc"; + if (catalog === "normal" || catalog === undefined) return "normal"; + throw new CatalogInputError("Invalid catalog"); +} + +function catalogLabel(kind: BulkCatalogKind): string { + return kind === "bc" ? "Builder Club" : "catalog"; +} + +const FIELD_LABELS: Record = { + costCredits: "credits", + costPoints: "points", + pointsType: "points type", + pageId: "category", +}; + +/** + * The audit log has no undo of its own, so it must record *what* changed, not + * just that something did. An entry that only says "bulk updated 200 offers" + * cannot answer the question the log exists for. + */ +function describeChanges(changes: BulkOfferInput["changes"]): string { + return Object.entries(changes) + .map(([key, change]) => { + if (change && typeof change === "object" && "mode" in change) + return `${FIELD_LABELS[key as keyof BulkOfferInput["changes"]]} ${ + change.mode === "percent" + ? `by ${change.value}%` + : `${change.mode} ${change.value}` + }`; + if (key === "pageId") return `category moved to #${String(change)}`; + return `${FIELD_LABELS[key as keyof BulkOfferInput["changes"]]} = ${String(change)}`; + }) + .join(", "); +} + +export async function previewBulkOffers( + input: BulkOfferInput, + catalog: unknown = "normal", +) { await requirePermission(PERMS.CATALOG_VIEW); try { - return { ok: true as const, data: await previewBulkOffersCommand(input) }; + return { + ok: true as const, + data: await previewBulkOffersCommand(input, bulkKind(catalog)), + }; } catch (error) { return { ok: false as const, @@ -31,8 +79,10 @@ export async function applyBulkOffers( input: BulkOfferInput, fingerprint: string, requestKey?: string, + catalog: unknown = "normal", ) { const staff = await requirePermission(PERMS.CATALOG_EDIT); + const kind = bulkKind(catalog); try { return await withCatalogExport(async () => { const data = await applyBulkOffersCommand( @@ -40,8 +90,16 @@ export async function applyBulkOffers( fingerprint, staff.id, requestKey, + kind, ); if (data.changedCount > 0) { + await logStaffActivity({ + staffId: staff.id, + action: "catalog_bulk_offers", + description: `Bulk updated ${data.changedCount} ${catalogLabel(kind)} offer(s) across ${new Set(input.ids).size} selected: ${describeChanges(input.changes)}`, + targetType: "catalog_page", + targetId: input.changes.pageId, + }); refreshCatalog(); } return { ok: true as const, data }; @@ -57,12 +115,12 @@ export async function applyBulkOffers( } } -export async function getBulkOfferDestinations() { +export async function getBulkOfferDestinations(catalog: unknown = "normal") { await requirePermission(PERMS.CATALOG_VIEW); try { return { ok: true as const, - data: await listBulkOfferDestinationsCommand(), + data: await listBulkOfferDestinationsCommand(bulkKind(catalog)), }; } catch (error) { return { @@ -87,6 +145,12 @@ export async function undoBulkOffers( staff.id, requestKey, ); + await logStaffActivity({ + staffId: staff.id, + action: "catalog_bulk_undo", + description: `Restored ${data.changedCount} catalog offer(s) from history #${historyIds.join(", ")}`, + targetType: "catalog_offer", + }); refreshCatalog(); return { ok: true as const, data }; }); diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index 23c47c4f..07b4d560 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -3,6 +3,12 @@ import { eq, inArray, like, or, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { offerPatchSchema } from "@/features/catalog/domain/offer-input"; +import { catalogFailure } from "@/features/catalog/server/errors"; +import { + deleteCatalogItemsCommand, + listRestorableDeletionsCommand, + restoreDeletedCatalogItemsCommand, +} from "@/features/catalog/server/item-deletes"; import { createOfferCommand, moveOffersCommand, @@ -196,20 +202,88 @@ export async function bulkCreateCatalogItems({ }); } -export async function deleteCatalogItems({ ids }: { ids: number[] }) { +/** + * Deleting offers is the one catalog mutation with no natural inverse, so the + * full rows are kept at delete time and `restoreId` is handed back. The UI shows + * that as an undo affordance; without it a mis-click is unrecoverable. + */ +export async function deleteCatalogItems({ + ids, + requestKey, +}: { + ids: number[]; + requestKey?: string; +}) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - return await withCatalogExport(async () => { - await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids)); - await sendCatalogUpdate(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_items_delete", - description: `Deleted catalog items: ${ids.join(", ")}`, - targetType: "catalog_item", + try { + return await withCatalogExport(async () => { + const data: { + deleted: number; + restoreId: number; + } = await deleteCatalogItemsCommand(ids, staff.id, requestKey); + await sendCatalogUpdate(); + await logStaffActivity({ + staffId: staff.id, + action: "catalog_items_delete", + description: `Deleted ${data.deleted} catalog offer(s): ${ids.join(", ")}`, + targetType: "catalog_item", + }); + revalidatePath("/admin/catalog"); + return { ok: true as const, data }; }); - revalidatePath("/admin/catalog"); - return { ok: true as const, data: {} }; - }); + } catch (error) { + const failure = catalogFailure(error); + return { ok: false as const, error: failure.message }; + } +} + +/** + * Recent deletions that are still restorable. The undo toast covers the common + * case; this is the fallback for a delete noticed after that toast is gone. + */ +export async function listRestorableCatalogItemDeletions() { + await requirePermission(PERMS.CATALOG_EDIT); + try { + return { + ok: true as const, + data: await listRestorableDeletionsCommand(), + }; + } catch (error) { + const failure = catalogFailure(error); + return { ok: false as const, error: failure.message }; + } +} + +export async function restoreDeletedCatalogItems({ + restoreId, + requestKey, +}: { + restoreId: number; + requestKey?: string; +}) { + const staff = await requirePermission(PERMS.CATALOG_EDIT); + try { + return await withCatalogExport(async () => { + const data: { restored: number } = + await restoreDeletedCatalogItemsCommand( + restoreId, + staff.id, + requestKey, + ); + await sendCatalogUpdate(); + await logStaffActivity({ + staffId: staff.id, + action: "catalog_items_restore", + description: `Restored ${data.restored} deleted catalog offer(s)`, + targetType: "catalog_item", + }); + revalidatePath("/admin/catalog"); + return { ok: true as const, data }; + }); + } catch (error) { + const failure = catalogFailure(error); + return { ok: false as const, error: failure.message }; + } } export async function moveCatalogItems({ diff --git a/src/app/api/admin/catalog/status/route.ts b/src/app/api/admin/catalog/status/route.ts index 9c91c744..890de8b5 100644 --- a/src/app/api/admin/catalog/status/route.ts +++ b/src/app/api/admin/catalog/status/route.ts @@ -1,4 +1,5 @@ import { + readCatalogHotelHistory, readCatalogHotelStatus, sendCatalogUpdate, } from "@/features/catalog/server/sync-status"; @@ -7,11 +8,12 @@ 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([ + const [hotel, history, git] = await Promise.all([ readCatalogHotelStatus(), + readCatalogHotelHistory(), catalogExportStatus(), ]); - return apiOk({ hotel, git }); + return apiOk({ hotel, history, git }); }); export const POST = withAdmin({ permission: PERMS.CATALOG_EDIT }, async () => apiOk({ hotel: await sendCatalogUpdate() }), diff --git a/src/app/api/admin/catalog/tree/route.ts b/src/app/api/admin/catalog/tree/route.ts index 5f69cc6e..23fe3be7 100644 --- a/src/app/api/admin/catalog/tree/route.ts +++ b/src/app/api/admin/catalog/tree/route.ts @@ -1,4 +1,3 @@ -import { asc, count, eq, inArray, like, or } from "drizzle-orm"; import { NextResponse } from "next/server"; import { readCatalogRevision } from "@/features/catalog/server/catalog-revision"; import { loadCatalogTotals } from "@/features/catalog/server/catalog-totals"; @@ -8,354 +7,30 @@ import { togglePageCommand, } from "@/features/catalog/server/page-commands"; import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; +import { + type CatalogKind, + readAncestors, + readChildren, + readFullTree, + readPage, + searchPages, +} from "@/features/catalog/server/tree-nodes"; 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 { - getAncestors, - getCatalogItemCounts, - getTreeFlat, - toInt, -} from "@/lib/services/catalog-tree"; -import type { TreeNode } from "@/types/catalog"; - -type PageRow = { - id: number; - caption: string; - parentId: number; - orderNum: number; - enabled: string; - visible: string; - iconImage: number; - iconColor: number; - pageLayout: string; -}; - -async function getBcTreeFlat(): Promise { - const [allPages, itemCounts] = await Promise.all([ - db - .select({ - id: CatalogPagesBc.id, - parentId: CatalogPagesBc.parentId, - caption: CatalogPagesBc.caption, - pageLayout: CatalogPagesBc.pageLayout, - iconColor: CatalogPagesBc.iconColor, - iconImage: CatalogPagesBc.iconImage, - orderNum: CatalogPagesBc.orderNum, - visible: CatalogPagesBc.visible, - enabled: CatalogPagesBc.enabled, - }) - .from(CatalogPagesBc) - .orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id)), - db - .select({ - pageId: CatalogItemsBc.pageId, - total: count(), - }) - .from(CatalogItemsBc) - .groupBy(CatalogItemsBc.pageId), - ]); - const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c.total])); - const childCountMap = new Map(); - for (const p of allPages) { - childCountMap.set(p.parentId, (childCountMap.get(p.parentId) ?? 0) + 1); - } - const pageMap = new Map(allPages.map((p) => [p.id, p])); - const depthCache = new Map(); - function computeDepth(pageId: number, visiting = new Set()): 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, visiting) + 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, - })); -} - -function toTreeNode( - p: PageRow, - childCount: number, - itemCount: number, - depth = 0, -): TreeNode { - return { - id: p.id, - caption: p.caption, - parentId: p.parentId, - depth, - orderNum: p.orderNum, - enabled: String(p.enabled), - visible: String(p.visible), - iconImage: p.iconImage, - iconColor: p.iconColor, - pageLayout: p.pageLayout, - childCount, - itemCount, - }; -} - -async function getChildren( - parentId: number, - isBc: boolean, -): Promise { - // Roots in Habbo DBs are usually parent_id = -1, sometimes 0. - const parentFilter = - parentId <= 0 - ? or(eq(CatalogPages.parentId, -1), eq(CatalogPages.parentId, 0)) - : eq(CatalogPages.parentId, parentId); - const parentFilterBc = - parentId <= 0 - ? or(eq(CatalogPagesBc.parentId, -1), eq(CatalogPagesBc.parentId, 0)) - : eq(CatalogPagesBc.parentId, parentId); - - if (isBc) { - const pages = await db - .select({ - id: CatalogPagesBc.id, - parentId: CatalogPagesBc.parentId, - caption: CatalogPagesBc.caption, - pageLayout: CatalogPagesBc.pageLayout, - iconColor: CatalogPagesBc.iconColor, - iconImage: CatalogPagesBc.iconImage, - orderNum: CatalogPagesBc.orderNum, - visible: CatalogPagesBc.visible, - enabled: CatalogPagesBc.enabled, - }) - .from(CatalogPagesBc) - .where(parentFilterBc) - .orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id)); - const ids = pages.map((p) => toInt(p.id)); - const [childCounts, itemCounts] = await Promise.all([ - ids.length - ? db - .select({ - parentId: CatalogPagesBc.parentId, - total: count(), - }) - .from(CatalogPagesBc) - .where(inArray(CatalogPagesBc.parentId, ids)) - .groupBy(CatalogPagesBc.parentId) - : Promise.resolve([]), - ids.length - ? db - .select({ - pageId: CatalogItemsBc.pageId, - total: count(), - }) - .from(CatalogItemsBc) - .where(inArray(CatalogItemsBc.pageId, ids)) - .groupBy(CatalogItemsBc.pageId) - .catch(() => []) - : Promise.resolve([]), - ]); - const childMap = Object.fromEntries( - childCounts.map((c) => [toInt(c.parentId), c.total]), - ); - const itemMap = Object.fromEntries( - itemCounts.map((c) => [toInt(c.pageId), c.total]), - ); - 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 db - .select({ - id: CatalogPages.id, - parentId: CatalogPages.parentId, - caption: CatalogPages.caption, - pageLayout: CatalogPages.pageLayout, - iconColor: CatalogPages.iconColor, - iconImage: CatalogPages.iconImage, - orderNum: CatalogPages.orderNum, - visible: CatalogPages.visible, - enabled: CatalogPages.enabled, - }) - .from(CatalogPages) - .where(parentFilter) - .orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id)); - const ids = pages.map((p) => toInt(p.id)); - const [childCounts, itemMap] = await Promise.all([ - ids.length - ? db - .select({ - parentId: CatalogPages.parentId, - total: count(), - }) - .from(CatalogPages) - .where(inArray(CatalogPages.parentId, ids)) - .groupBy(CatalogPages.parentId) - : Promise.resolve([]), - getCatalogItemCounts(ids), - ]); - const childMap = Object.fromEntries( - childCounts.map((c) => [toInt(c.parentId), c.total]), - ); - 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 { - const needle = q.trim(); - const idExact = Number.parseInt(needle, 10); - const idOk = Number.isFinite(idExact) && String(idExact) === needle; - - if (isBc) { - const conditions = [like(CatalogPagesBc.caption, `%${needle}%`)]; - if (idOk) conditions.push(eq(CatalogPagesBc.id, idExact)); - const pages = await db - .select({ - id: CatalogPagesBc.id, - parentId: CatalogPagesBc.parentId, - caption: CatalogPagesBc.caption, - pageLayout: CatalogPagesBc.pageLayout, - iconColor: CatalogPagesBc.iconColor, - iconImage: CatalogPagesBc.iconImage, - orderNum: CatalogPagesBc.orderNum, - visible: CatalogPagesBc.visible, - enabled: CatalogPagesBc.enabled, - }) - .from(CatalogPagesBc) - .where(or(...conditions)) - .orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id)) - .limit(50); - return pages.map((p) => - toTreeNode( - { - id: toInt(p.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, - }, - 0, - 0, - ), - ); - } - - const conditions = [like(CatalogPages.caption, `%${needle}%`)]; - if (idOk) conditions.push(eq(CatalogPages.id, idExact)); - const pages = await db - .select({ - id: CatalogPages.id, - parentId: CatalogPages.parentId, - caption: CatalogPages.caption, - pageLayout: CatalogPages.pageLayout, - iconColor: CatalogPages.iconColor, - iconImage: CatalogPages.iconImage, - orderNum: CatalogPages.orderNum, - visible: CatalogPages.visible, - enabled: CatalogPages.enabled, - }) - .from(CatalogPages) - .where(or(...conditions)) - .orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id)) - .limit(50); - const ids = pages.map((p) => toInt(p.id)); - const [childCounts, itemMap] = await Promise.all([ - ids.length - ? db - .select({ - parentId: CatalogPages.parentId, - total: count(), - }) - .from(CatalogPages) - .where(inArray(CatalogPages.parentId, ids)) - .groupBy(CatalogPages.parentId) - : Promise.resolve([]), - getCatalogItemCounts(ids), - ]); - const childMap = Object.fromEntries( - childCounts.map((c) => [toInt(c.parentId), c.total]), - ); - 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, - ); - }); -} export const GET = withAdmin( { permission: PERMS.CATALOG_VIEW }, async (request) => { const sp = request.nextUrl.searchParams; - const isBc = sp.get("catalog") === "bc"; + const kind: CatalogKind = sp.get("catalog") === "bc" ? "bc" : "normal"; if (sp.get("mode") === "full") { // Totals travel with the tree so one refresh updates both the rail and // the stats bar. Read uncached — this response is already the fresh read. const [pages, totals, revision] = await Promise.all([ - isBc ? getBcTreeFlat() : getTreeFlat(), - loadCatalogTotals(isBc ? "bc" : "normal"), + readFullTree(kind), + loadCatalogTotals(kind), readCatalogRevision(), ]); // `revision` doubles as the ETag: a client that saw this exact catalog @@ -376,20 +51,7 @@ export const GET = withAdmin( if (pageIdRaw) { const id = Number(pageIdRaw); if (!Number.isFinite(id) || id <= 0) return apiError("Invalid pageId"); - if (isBc) { - const [page] = await db - .select() - .from(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, id)) - .limit(1); - if (!page) return apiError("Page not found", 404); - return apiOk({ page }); - } - const [page] = await db - .select() - .from(CatalogPages) - .where(eq(CatalogPages.id, id)) - .limit(1); + const page = await readPage(kind, id); if (!page) return apiError("Page not found", 404); return apiOk({ page }); } @@ -398,44 +60,20 @@ export const GET = withAdmin( if (ancestorsRaw) { const id = Number(ancestorsRaw); if (!Number.isFinite(id) || id <= 0) return apiError("Invalid id"); - if (isBc) { - const chain: Array<{ id: number; caption: string }> = []; - let currentId = id; - for (let i = 0; i < 20; i++) { - const [page] = await db - .select({ - id: CatalogPagesBc.id, - caption: CatalogPagesBc.caption, - parentId: CatalogPagesBc.parentId, - }) - .from(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, currentId)) - .limit(1); - if (!page) break; - chain.unshift({ id: page.id, caption: page.caption }); - if (page.parentId <= 0) break; - currentId = page.parentId; - } - return apiOk({ - ancestors: chain.filter((a) => a.id !== id), - }); - } - const chain = await getAncestors(id); + const chain = await readAncestors(kind, id); return apiOk({ ancestors: chain.filter((a) => a.id !== id) }); } const search = sp.get("search"); if (search?.trim()) { - const pages = await searchPages(search.trim(), isBc); - return apiOk({ pages }); + return apiOk({ pages: await searchPages(kind, search.trim()) }); } const parentIdRaw = sp.get("parentId"); if (parentIdRaw !== null) { const parentId = Number(parentIdRaw); if (!Number.isFinite(parentId)) return apiError("Invalid parentId"); - const pages = await getChildren(parentId, isBc); - return apiOk({ pages }); + return apiOk({ pages: await readChildren(kind, parentId) }); } return apiOk({ pages: [] }); @@ -455,9 +93,9 @@ export const PATCH = withAdmin( if (!Number.isFinite(pageId) || pageId <= 0) { return apiError("Invalid pageId"); } - const isBc = body.catalog === "bc"; + const kind: CatalogKind = body.catalog === "bc" ? "bc" : "normal"; try { - await togglePageCommand(isBc ? "bc" : "normal", pageId, field); + await togglePageCommand(kind, pageId, field); } catch (error) { const failure = catalogFailure(error); return apiError(failure.message, failure.status); @@ -473,12 +111,12 @@ export const DELETE = withAdmin( const sp = request.nextUrl.searchParams; const pageId = Number(sp.get("pageId")); const mode = sp.get("mode") === "cascade" ? "cascade" : "reparent"; - const isBc = sp.get("catalog") === "bc"; + const kind: CatalogKind = sp.get("catalog") === "bc" ? "bc" : "normal"; if (!Number.isFinite(pageId) || pageId <= 0) { return apiError("Invalid pageId"); } try { - await deletePageCommand(isBc ? "bc" : "normal", pageId, mode); + await deletePageCommand(kind, pageId, mode); } catch (error) { const failure = catalogFailure(error); return apiError(failure.message, failure.status); diff --git a/src/components/admin/catalog-manager/inline-editor.tsx b/src/components/admin/catalog-manager/inline-editor.tsx index 339f9a91..7cee005b 100644 --- a/src/components/admin/catalog-manager/inline-editor.tsx +++ b/src/components/admin/catalog-manager/inline-editor.tsx @@ -830,6 +830,7 @@ function InlineEditorSession({ interactionTypes={itemsData.interactionTypes} furniRevisionMap={itemsData.furniRevisionMap} allPages={itemsData.allPages} + catalog={catalogType} /> ) : null} diff --git a/src/components/admin/catalog/catalog-items-table/catalog-items-table.tsx b/src/components/admin/catalog/catalog-items-table/catalog-items-table.tsx index 3d847b60..569477e8 100644 --- a/src/components/admin/catalog/catalog-items-table/catalog-items-table.tsx +++ b/src/components/admin/catalog/catalog-items-table/catalog-items-table.tsx @@ -41,6 +41,7 @@ import { deleteCatalogItems, moveCatalogItems, reorderCatalogItems, + restoreDeletedCatalogItems, updateCatalogItem, } from "@/actions/catalog-items"; import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items"; @@ -97,6 +98,7 @@ import { ToggleField, } from "./field-helpers"; import { FurniIcon } from "./furni-icon"; +import { RecentDeletionsPanel } from "./recent-deletions-panel"; import { SongPicker } from "./song-picker"; import { type BaseItemData, @@ -168,6 +170,8 @@ interface CatalogItemsTableProps { interactionTypes: string[]; furniRevisionMap: Record; allPages: { id: number; caption: string }[]; + /** Which catalog these offers belong to; bulk editing follows it. */ + catalog?: "normal" | "bc"; } export function CatalogItemsTable({ @@ -181,6 +185,7 @@ export function CatalogItemsTable({ interactionTypes, furniRevisionMap, allPages, + catalog = "normal", }: CatalogItemsTableProps) { const bulk = useTranslations("pages.admin.catalog.bulk"); const discovery = useTranslations("pages.admin.catalog.discovery"); @@ -192,6 +197,33 @@ export function CatalogItemsTable({ >(null); const { run, isPending } = useServerAction(); const { confirm, dialog: confirmDialog } = useConfirmDialog(); + /** + * A delete keeps every removed row, so it can be taken back. The affordance + * lives in the toast rather than in the toolbar because the window is short: + * the audit record outlives it, but a bulk delete is not something to restore + * from a dropdown ten minutes later. + */ + function offerUndoDelete(restoreId: number, count: number) { + if (!Number.isSafeInteger(restoreId) || restoreId < 1) return; + // The panel below holds the same records, so a restore missed here is not a + // lost delete. + toast.success(`Deleted ${count} item(s).`, { + action: { + label: "Undo", + onClick: () => { + void restoreDeletedCatalogItems({ + restoreId, + requestKey: crypto.randomUUID(), + }).then((result) => { + onRefresh(); + if (!result.ok) + toast.error(result.error ?? "Could not restore the items."); + else toast.success(`Restored ${result.data.restored} item(s).`); + }); + }, + }, + }); + } const [addOpen, setAddOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); const [editingBase, setEditingBase] = useState(null); @@ -531,12 +563,14 @@ export function CatalogItemsTable({ }); if (!ok) return; - run(() => deleteCatalogItems({ ids: [...selected] }), { - successMessage: `Deleted ${selected.size} item(s).`, + const ids = [...selected]; + run(() => deleteCatalogItems({ ids, requestKey: crypto.randomUUID() }), { + successMessage: `Deleted ${ids.length} item(s).`, errorMessage: "Failed to delete items.", - onSuccess: () => { + onSuccess: (data) => { setSelected(new Set()); onRefresh(); + offerUndoDelete(Number(data.restoreId), ids.length); }, }); } @@ -684,11 +718,17 @@ export function CatalogItemsTable({ confirmLabel: "Delete", }); if (!ok) return; - run(() => deleteCatalogItems({ ids: [id] }), { - successMessage: "Item deleted.", - errorMessage: "Failed to delete item.", - onSuccess: () => onRefresh(), - }); + run( + () => deleteCatalogItems({ ids: [id], requestKey: crypto.randomUUID() }), + { + successMessage: "Item deleted.", + errorMessage: "Failed to delete item.", + onSuccess: (data) => { + onRefresh(); + offerUndoDelete(Number(data.restoreId), 1); + }, + }, + ); } // ── Move single item to another page ───────────────────────── @@ -805,6 +845,16 @@ export function CatalogItemsTable({ return ( <> {confirmDialog} + {canEdit && ( +
+ + Recent deletions + +
+ +
+
+ )} {bulkRefreshFrom !== null && (

void; +}) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [restoring, setRestoring] = useState(null); + const [failed, setFailed] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setFailed(false); + try { + const result = await listRestorableCatalogItemDeletions(); + if (result.ok) setEntries(result.data); + else setFailed(true); + } catch { + setFailed(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function restore(entry: RestorableDeletion) { + if (restoring !== null) return; + setRestoring(entry.restoreId); + try { + const { restoreDeletedCatalogItems } = await import( + "@/actions/catalog-items" + ); + const result = await restoreDeletedCatalogItems({ + restoreId: entry.restoreId, + requestKey: crypto.randomUUID(), + }); + if (!result.ok) { + toast.error(result.error ?? "Could not restore those offers."); + // The reason is usually that an id got reused, which is permanent: + // re-listing so the entry stops offering a restore that cannot work. + await load(); + return; + } + toast.success(`Restored ${result.data.restored} offer(s).`); + onRestored(); + await load(); + } catch { + toast.error("Could not restore those offers."); + } finally { + setRestoring(null); + } + } + + if (loading) { + return ( +

+ + Loading recent deletions… +

+ ); + } + + if (failed) { + return ( +
+ + Could not load recent deletions. + + +
+ ); + } + + if (!entries.length) return null; + + return ( +
+

+ Recent deletions — restorable +

+
    + {entries.map((entry) => ( +
  • + + {entry.offers} offer(s) ·{" "} + + + +
  • + ))} +
+
+ ); +} diff --git a/src/features/catalog/components/bulk-offer-editor.tsx b/src/features/catalog/components/bulk-offer-editor.tsx index ec248c92..00db4a15 100644 --- a/src/features/catalog/components/bulk-offer-editor.tsx +++ b/src/features/catalog/components/bulk-offer-editor.tsx @@ -59,12 +59,15 @@ const emptyPrice = (): PriceDraft => ({ export function BulkOfferEditor({ ids, allPages, + catalog = "normal", disabled = false, onApplied, beforeEdit, }: { ids: number[]; allPages: { id: number; caption: string }[]; + /** Which catalog the selection belongs to. BC has no undo history. */ + catalog?: "normal" | "bc"; disabled?: boolean; onApplied: () => void | Promise; beforeEdit?: () => boolean; @@ -145,7 +148,7 @@ export function BulkOfferEditor({ setBusy(true); setError(null); try { - const result = await previewBulkOffers(input); + const result = await previewBulkOffers(input, catalog); if (!result.ok) { setError(result.error); return; @@ -174,6 +177,7 @@ export function BulkOfferEditor({ preview.input, preview.data.fingerprint, preview.requestKey, + catalog, ); if (!result.ok) { setError(result.error); diff --git a/src/features/catalog/components/catalog-search.tsx b/src/features/catalog/components/catalog-search.tsx index 03499dc0..3019791f 100644 --- a/src/features/catalog/components/catalog-search.tsx +++ b/src/features/catalog/components/catalog-search.tsx @@ -68,7 +68,7 @@ export function CatalogSearch({ setDestinationsLoading(true); setSelectionError(null); try { - const result = await getBulkOfferDestinations(); + const result = await getBulkOfferDestinations(catalogType); if (request !== destinationRequest.current) return; if (!result.ok) { setSelectionError(result.error); @@ -152,7 +152,7 @@ export function CatalogSearch({ className="min-w-0" /> - {canEdit && catalogType === "normal" && selected.size > 0 && ( + {canEdit && selected.size > 0 && (
{bulk("selectedCount", { count: selected.size })} @@ -168,6 +168,7 @@ export function CatalogSearch({ { setSelected(new Set()); diff --git a/src/features/catalog/server/bulk-actions.test.ts b/src/features/catalog/server/bulk-actions.test.ts index 3ef233be..9df570ed 100644 --- a/src/features/catalog/server/bulk-actions.test.ts +++ b/src/features/catalog/server/bulk-actions.test.ts @@ -109,6 +109,6 @@ it("undo denial stops before export and a conflict does not notify", async () => it("passes a stable operation key and does not synchronously notify after commit", async () => { const key = "123e4567-e89b-42d3-a456-426614174000"; expect((await applyBulkOffers(input, "x", key)).ok).toBe(true); - expect(state.apply).toHaveBeenCalledWith(input, "x", 1, key); + expect(state.apply).toHaveBeenCalledWith(input, "x", 1, key, "normal"); expect(state.send).not.toHaveBeenCalled(); }); diff --git a/src/features/catalog/server/bulk-offers.ts b/src/features/catalog/server/bulk-offers.ts index 5dcd3e35..c8d57b2a 100644 --- a/src/features/catalog/server/bulk-offers.ts +++ b/src/features/catalog/server/bulk-offers.ts @@ -23,15 +23,30 @@ import { } from "../domain/hierarchy"; type Tx = Parameters[0]>[0]; +export type BulkCatalogKind = "normal" | "bc"; + +/** + * The BC catalog has the same offer columns under a second table, so bulk + * editing is a table choice rather than a second code path. Only normal offers + * carry `offer_id`, which is why the undo history kind stays single-mode. + */ +function offerTable(kind: BulkCatalogKind) { + return sql.identifier(kind === "bc" ? "catalog_items_bc" : "catalog_items"); +} +function pageTable(kind: BulkCatalogKind) { + return sql.identifier(kind === "bc" ? "catalog_pages_bc" : "catalog_pages"); +} + type OfferRow = BulkOfferValues & { id: number; catalogName: string }; async function readOffers( tx: Tx, ids: number[], + kind: BulkCatalogKind, lock = false, ): Promise { const offers = rowsFrom( await tx.execute( - sql`SELECT id, catalog_name AS catalogName, page_id AS pageId, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType FROM catalog_items WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`, + sql`SELECT id, catalog_name AS catalogName, page_id AS pageId, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType FROM ${offerTable(kind)} WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`, ), ).map((row) => ({ ...row, @@ -54,6 +69,7 @@ async function readPages( tx: Tx, offers: OfferRow[], input: BulkOfferInput, + kind: BulkCatalogKind, lock = false, ) { const ids = [ @@ -66,7 +82,7 @@ async function readPages( throw new CatalogInputError("An offer has an invalid category reference"); const pages = rowsFrom<{ id: number; caption: string }>( await tx.execute( - sql`SELECT id, caption FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`, + sql`SELECT id, caption FROM ${pageTable(kind)} WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`, ), ) .filter((row) => ids.includes(Number(row.id))) @@ -107,11 +123,12 @@ function preview( } export async function previewBulkOffersCommand( value: BulkOfferInput, + kind: BulkCatalogKind = "normal", ): Promise { const input = bulkOfferInputSchema.parse(value); return db.transaction(async (tx) => { - const offers = await readOffers(tx, input.ids); - return preview(input, offers, await readPages(tx, offers, input)); + const offers = await readOffers(tx, input.ids, kind); + return preview(input, offers, await readPages(tx, offers, input, kind)); }); } export async function applyBulkOffersCommand( @@ -119,15 +136,16 @@ export async function applyBulkOffersCommand( fingerprint: string, userId?: number, requestKey: string = randomUUID(), + kind: BulkCatalogKind = "normal", ) { const input = bulkOfferInputSchema.parse(value); if (!/^[a-f0-9]{64}$/.test(fingerprint)) throw new CatalogInputError("A valid preview is required"); const work = async (tx: Tx, operationId?: string) => { // Read references, then lock pages before offers to match category structural commands. - const initial = await readOffers(tx, input.ids); - const pages = await readPages(tx, initial, input, true); - const offers = await readOffers(tx, input.ids, true); + const initial = await readOffers(tx, input.ids, kind); + const pages = await readPages(tx, initial, input, kind, true); + const offers = await readOffers(tx, input.ids, kind, true); if (offers.some((row) => !pages.some((page) => page.id === row.pageId))) throw new CatalogConflict("The selection changed. Refresh the preview."); const result = preview(input, offers, pages); @@ -187,6 +205,10 @@ export async function applyBulkOffersCommand( ...input, ids: [...input.ids].sort((a, b) => a - b), fingerprint, + // The catalog is part of the identity of this request: a replay + // with the same key against the other catalog must not be + // treated as the same work. + catalog: kind, }, }, work, @@ -194,10 +216,12 @@ export async function applyBulkOffersCommand( : db.transaction((tx) => work(tx)); } -export async function listBulkOfferDestinationsCommand() { +export async function listBulkOfferDestinationsCommand( + kind: BulkCatalogKind = "normal", +) { const pages = ( await queryRows<{ id: number; caption: string }>( - sql`SELECT id, caption FROM catalog_pages WHERE id > 0 ORDER BY caption, id LIMIT 5001`, + sql`SELECT id, caption FROM ${pageTable(kind)} WHERE id > 0 ORDER BY caption, id LIMIT 5001`, ) ).map((row) => ({ id: Number(row.id), caption: row.caption })); if (pages.length > 5000) diff --git a/src/features/catalog/server/item-deletes.test.ts b/src/features/catalog/server/item-deletes.test.ts new file mode 100644 index 00000000..c5938b93 --- /dev/null +++ b/src/features/catalog/server/item-deletes.test.ts @@ -0,0 +1,411 @@ +import type { SQL } from "drizzle-orm"; +import { int, MySqlDialect } from "drizzle-orm/mysql-core"; +import { beforeEach, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + rows: [ + { + id: 1, + itemIds: "10", + pageId: 4, + catalogName: "Chair", + costCredits: 3, + costPoints: 10, + pointsType: 5, + amount: 1, + limitedStack: 0, + limitedSells: 0, + orderNumber: 1, + offerId: -1, + songId: 0, + extradata: "", + haveOffer: "1", + clubOnly: "0", + }, + { + id: 2, + itemIds: "20", + pageId: 4, + catalogName: "Table", + costCredits: 8, + costPoints: 0, + pointsType: 0, + amount: 1, + limitedStack: 0, + limitedSells: 0, + orderNumber: 2, + offerId: -1, + songId: 0, + extradata: "", + haveOffer: "1", + clubOnly: "0", + }, + ], + // Categories that still exist; a restore must not write offers onto + // categories that are gone. + pages: [4] as number[], + audit: [] as Array<{ + id: number; + action: string; + target: string; + userId: number; + createdAt: string; + before: string | null; + }>, + queries: [] as string[], + nextAuditId: 900, + failInsert: false, + rollbacks: 0, +})); + +/** + * The restore lookup is the only audit read, and its condition is + * `sql\`${AdminAuditLog.id}=?\``, so the id is the condition's only numeric chunk. + */ +function auditIdFromCondition(condition: unknown): number { + // The id is the only numeric chunk. `FOR UPDATE` appends keyword chunks + // rather than params, so the restore lookup still parses. + const chunks = (condition as { queryChunks?: unknown[] }).queryChunks ?? []; + for (const chunk of chunks) if (typeof chunk === "number") return chunk; + return Number.NaN; +} + +vi.mock("@/lib/db", async () => { + const run = (query: SQL) => { + const { sql: text, params } = new MySqlDialect().sqlToQuery(query); + state.queries.push(text); + return { text, params }; + }; + return { + rowsFrom: (result: unknown) => (result as [unknown, unknown])[0] ?? [], + // A real column, so `sql\`${AdminAuditLog.id}=?\`` compiles the way it does + // in production and the mock can read the id back out of the chunks. + AdminAuditLog: { id: int("id").notNull() }, + db: { + execute: async (query: SQL) => { + const compiled = new MySqlDialect().sqlToQuery(query); + const text = compiled.sql; + state.queries.push(text); + // `LIMIT ?` is passed as a bound value by the dialect, so honour it + // rather than returning everything and making the cap untested. + const limit = Number(compiled.params.at(-1)); + const rows = + Number.isFinite(limit) && limit > 0 + ? state.audit.slice(-limit).reverse() + : [...state.audit].reverse(); + return [rows, []]; + }, + transaction: async (fn: (tx: unknown) => Promise) => { + const rowsBefore = structuredClone(state.rows); + const auditBefore = structuredClone(state.audit); + const pagesBefore = [...state.pages]; + try { + return await fn({ + execute: async (query: SQL) => { + const { text, params } = run(query); + if (text.startsWith("SELECT") && text.includes("catalog_pages")) { + return [ + state.pages + .filter((id) => + params.some((param) => Number(param) === id), + ) + .map((id) => ({ id })), + [], + ]; + } + if (text.startsWith("SELECT") && text.includes("catalog_items")) { + const wanted = text.includes("FROM catalog_items WHERE id IN"); + return [ + wanted + ? state.rows.filter((row) => + params.some((param) => Number(param) === row.id), + ) + : [], + [], + ]; + } + if (text.startsWith("DELETE FROM catalog_items")) { + for (const param of params) + state.rows = state.rows.filter( + (row) => row.id !== Number(param), + ); + return [{ affectedRows: params.length }, []]; + } + if (text.startsWith("INSERT INTO catalog_items")) { + state.rows.push({ + id: Number(params[0]), + itemIds: String(params[1]), + pageId: Number(params[2]), + catalogName: String(params[3]), + costCredits: Number(params[4]), + costPoints: Number(params[5]), + pointsType: Number(params[6]), + amount: Number(params[7]), + limitedStack: Number(params[8]), + limitedSells: Number(params[9]), + orderNumber: Number(params[10]), + offerId: Number(params[11]), + songId: Number(params[12]), + extradata: String(params[13]), + haveOffer: String(params[14]), + clubOnly: String(params[15]), + }); + return [{ affectedRows: 1 }, []]; + } + return [{ affectedRows: 1 }, []]; + }, + insert: () => ({ + values: async (value: Record) => { + if (state.failInsert) throw Error("audit unavailable"); + const id = state.nextAuditId++; + state.audit.push({ + id, + action: String(value.action), + target: String(value.target), + userId: Number(value.userId ?? 0), + createdAt: String(value.createdAt ?? ""), + before: (value.before ?? null) as string | null, + }); + return [{ insertId: id }]; + }, + }), + select: () => ({ + from: () => ({ + where: (condition: unknown) => ({ + limit: async () => { + // The only select on the audit table is the restore + // lookup, keyed by the id the delete returned. + const id = auditIdFromCondition(condition); + const entry = state.audit.find((row) => row.id === id); + return entry ? [entry] : []; + }, + }), + }), + }), + // Awaitable, like a real delete builder: the code awaits the + // `where()` result directly rather than calling `.execute()`. + delete: () => ({ + where: (condition: unknown) => { + const id = auditIdFromCondition(condition); + state.audit = state.audit.filter((row) => row.id !== id); + return Promise.resolve([{ affectedRows: 1 }, []]); + }, + }), + }); + } catch (error) { + state.rows = rowsBefore; + state.audit = auditBefore; + state.pages = pagesBefore; + state.rollbacks++; + throw error; + } + }, + }, + }; +}); + +vi.mock("@/features/operations/server", async () => { + // `runOperation` normally owns the transaction; here it delegates to the same + // mocked db so the real command body runs inside a real transaction scope. + const { db } = await import("@/lib/db"); + return { + runOperation: async ( + _input: unknown, + work: (tx: unknown, id: string) => Promise, + ) => (db as any).transaction((tx: unknown) => work(tx, "op-1")), + enqueueEffect: vi.fn(), + }; +}); + +vi.mock("@/lib/foundation/request-context", () => ({ + getOperationContext: () => ({ operationId: "req-1" }), +})); + +const { + deleteCatalogItemsCommand, + listRestorableDeletionsCommand, + restoreDeletedCatalogItemsCommand, +} = await import("./item-deletes"); + +beforeEach(() => { + state.rows = structuredClone([ + { + id: 1, + itemIds: "10", + pageId: 4, + catalogName: "Chair", + costCredits: 3, + costPoints: 10, + pointsType: 5, + amount: 1, + limitedStack: 0, + limitedSells: 0, + orderNumber: 1, + offerId: -1, + songId: 0, + extradata: "", + haveOffer: "1", + clubOnly: "0", + }, + { + id: 2, + itemIds: "20", + pageId: 4, + catalogName: "Table", + costCredits: 8, + costPoints: 0, + pointsType: 0, + amount: 1, + limitedStack: 0, + limitedSells: 0, + orderNumber: 2, + offerId: -1, + songId: 0, + extradata: "", + haveOffer: "1", + clubOnly: "0", + }, + ] as typeof state.rows); + state.audit = []; + state.pages = [4]; + state.queries = []; + state.nextAuditId = 900; + state.failInsert = false; + state.rollbacks = 0; +}); + +it("keeps every column so a delete can be undone", async () => { + const result = await deleteCatalogItemsCommand([1], 7); + + expect(result.deleted).toBe(1); + expect(state.rows.map((row) => row.id)).toEqual([2]); + const entry = state.audit.at(-1); + expect(entry?.action).toBe("catalog_items_delete_restore"); + expect(JSON.parse(entry?.before ?? "[]")).toEqual([ + expect.objectContaining({ + id: 1, + itemIds: "10", + catalogName: "Chair", + costCredits: 3, + haveOffer: "1", + }), + ]); +}); + +it("restores the exact row that was removed", async () => { + const { restoreId } = await deleteCatalogItemsCommand([1, 2], 7); + expect(state.rows).toHaveLength(0); + + const restored = await restoreDeletedCatalogItemsCommand(restoreId, 7); + + expect(restored.restored).toBe(2); + expect(state.rows.map((row) => row.id).sort()).toEqual([1, 2]); + expect(state.rows.find((row) => row.id === 1)).toMatchObject({ + itemIds: "10", + catalogName: "Chair", + costCredits: 3, + pointsType: 5, + }); +}); + +it("refuses to restore over an id another offer now uses", async () => { + const { restoreId } = await deleteCatalogItemsCommand([1], 7); + state.rows.push({ + id: 1, + itemIds: "99", + pageId: 4, + catalogName: "Replacement", + costCredits: 1, + costPoints: 0, + pointsType: 0, + amount: 1, + limitedStack: 0, + limitedSells: 0, + orderNumber: 1, + offerId: -1, + songId: 0, + extradata: "", + haveOffer: "1", + clubOnly: "0", + }); + + await expect(restoreDeletedCatalogItemsCommand(restoreId, 7)).rejects.toThrow( + /Other offers now use these ids/, + ); + // The competing offer must survive the refused restore. + expect(state.rows.find((row) => row.id === 1)?.catalogName).toBe( + "Replacement", + ); +}); + +it("refuses to restore offers whose category is gone", async () => { + const { restoreId } = await deleteCatalogItemsCommand([1], 7); + // The category was deleted after the offers came out of it. + state.pages = []; + + await expect(restoreDeletedCatalogItemsCommand(restoreId, 7)).rejects.toThrow( + /no longer exist/, + ); + // An offer pointing at nothing would sell nowhere and show under no page; + // offer #1 stays deleted, and #2 was never touched. + expect(state.rows.map((row) => row.id)).toEqual([2]); +}); + +it("restores normally when the category is still there", async () => { + const { restoreId } = await deleteCatalogItemsCommand([1], 7); + expect((await restoreDeletedCatalogItemsCommand(restoreId, 7)).restored).toBe( + 1, + ); +}); + +it("lists a past deletion after its toast window has gone", async () => { + const { restoreId } = await deleteCatalogItemsCommand([1, 2], 7); + + const listed = await listRestorableDeletionsCommand(); + + expect(listed).toHaveLength(1); + expect(listed[0]).toMatchObject({ restoreId, offers: 2, staffId: 7 }); + // And it still restores from that record. + expect((await restoreDeletedCatalogItemsCommand(restoreId, 7)).restored).toBe( + 2, + ); + expect(await listRestorableDeletionsCommand()).toEqual([]); + // The record is spent once the rows are back: a second restore cannot happen. + expect(state.audit).toEqual([]); +}); + +it("caps how many past deletions it lists", async () => { + await deleteCatalogItemsCommand([1], 7); + await deleteCatalogItemsCommand([2], 7); + expect(await listRestorableDeletionsCommand(1)).toHaveLength(1); +}); + +it("skips a corrupt record instead of failing the whole list", async () => { + await deleteCatalogItemsCommand([1], 7); + state.audit.push({ + id: 5000, + action: "catalog_items_delete_restore", + target: "catalog_items", + userId: 7, + createdAt: "", + before: "{not json", + }); + + const listed = await listRestorableDeletionsCommand(); + + expect(listed.map((row) => row.offers)).toEqual([1]); +}); + +it("deletes nothing when part of the selection is already gone", async () => { + await expect(deleteCatalogItemsCommand([1, 99], 7)).rejects.toThrow(); + expect(state.rows.map((row) => row.id)).toEqual([1, 2]); + expect(state.rollbacks).toBe(1); +}); + +it("keeps the delete when the restore record cannot be written", async () => { + state.failInsert = true; + + await expect(deleteCatalogItemsCommand([1], 7)).rejects.toThrow(); + // Without a durable restore record the rows must come back, not be lost. + expect(state.rows.map((row) => row.id)).toEqual([1, 2]); +}); diff --git a/src/features/catalog/server/item-deletes.ts b/src/features/catalog/server/item-deletes.ts new file mode 100644 index 00000000..5d557833 --- /dev/null +++ b/src/features/catalog/server/item-deletes.ts @@ -0,0 +1,278 @@ +import "server-only"; +import { randomUUID } from "node:crypto"; +import { sql } from "drizzle-orm"; +import { enqueueEffect, runOperation } from "@/features/operations/server"; +import { AdminAuditLog, db, rowsFrom } from "@/lib/db"; +import { getOperationContext } from "@/lib/foundation/request-context"; +import { CatalogInputError, CatalogNotFound } from "../domain/hierarchy"; + +/** + * A deleted offer cannot be restored from a field snapshot: the undo needs the + * row itself, because a delete takes every column with it and a later create may + * have reused the id. So the full row is serialised into the audit log at delete + * time and the restore is a re-insert of that exact row. + */ +const RESTORE_COLUMNS = [ + "id", + "itemIds", + "pageId", + "catalogName", + "costCredits", + "costPoints", + "pointsType", + "amount", + "limitedStack", + "limitedSells", + "orderNumber", + "offerId", + "songId", + "extradata", + "haveOffer", + "clubOnly", +] as const; + +type DeletedOffer = Record<(typeof RESTORE_COLUMNS)[number], string | number>; + +/** + * `haveOffer` is camelCase in the database itself; every other column is + * snake_case, so the mapping is written out once rather than derived per call. + */ +const COLUMN_SQL_NAMES: Record<(typeof RESTORE_COLUMNS)[number], string> = { + id: "id", + itemIds: "item_ids", + pageId: "page_id", + catalogName: "catalog_name", + costCredits: "cost_credits", + costPoints: "cost_points", + pointsType: "points_type", + amount: "amount", + limitedStack: "limited_stack", + limitedSells: "limited_sells", + orderNumber: "order_number", + offerId: "offer_id", + songId: "song_id", + extradata: "extradata", + haveOffer: "haveOffer", + clubOnly: "club_only", +}; + +const RESTORE_IDS = sql.join( + RESTORE_COLUMNS.map((key) => sql.identifier(COLUMN_SQL_NAMES[key])), + sql`, `, +); + +function deletedOfferSelect() { + return sql.join( + RESTORE_COLUMNS.map( + (key) => + sql`${sql.identifier(COLUMN_SQL_NAMES[key])} AS ${sql.identifier(key)}`, + ), + sql`, `, + ); +} + +const DELETE_ACTION = "catalog_items_delete_restore"; + +function parseOffers(raw: string | null): DeletedOffer[] { + let value: unknown; + try { + value = JSON.parse(raw ?? "null"); + } catch { + throw Error("unavailable"); + } + if (!Array.isArray(value) || !value.length) throw Error("unavailable"); + for (const row of value) { + if (!row || typeof row !== "object" || Array.isArray(row)) { + throw Error("unavailable"); + } + const record = row as Record; + if (!RESTORE_COLUMNS.every((key) => key in record)) { + throw Error("unavailable"); + } + } + return value as DeletedOffer[]; +} + +/** + * Delete offers, keeping enough of each row that the delete can be undone. + * Returns the audit id the client can restore from. + */ +export async function deleteCatalogItemsCommand( + ids: number[], + userId: number, + requestKey: string = randomUUID(), +) { + const selection = [...new Set(ids)].sort((a, b) => a - b); + if ( + !selection.length || + selection.length > 500 || + selection.some((id) => !Number.isSafeInteger(id) || id < 1) + ) + throw new CatalogInputError("Invalid selection"); + return runOperation( + { + actorId: userId, + kind: "catalog.items.delete", + key: requestKey, + input: { ids: selection }, + }, + async (tx, operationId) => { + const removed = rowsFrom( + await tx.execute( + sql`SELECT ${deletedOfferSelect()} FROM catalog_items WHERE id IN (${sql.join( + selection, + sql`, `, + )}) ORDER BY id FOR UPDATE`, + ), + ); + // A row that vanished under us means someone else already deleted it; + // restoring the rest anyway would leave the caller believing the whole + // selection succeeded. + if (removed.length !== selection.length) + throw new CatalogNotFound( + "Some selected offers were already deleted. Reload the selection.", + ); + await tx.execute( + sql`DELETE FROM catalog_items WHERE id IN (${sql.join(selection, sql`, `)})`, + ); + const inserted = await tx.insert(AdminAuditLog).values({ + userId, + action: DELETE_ACTION, + target: "catalog_items", + targetId: null, + details: JSON.stringify(getOperationContext()), + before: JSON.stringify(removed), + after: null, + createdAt: new Date().toISOString(), + }); + const restoreId = inserted?.[0]?.insertId; + if (!restoreId) throw Error("Delete could not be recorded for restore"); + await enqueueEffect(tx, operationId, "catalog.refresh"); + await enqueueEffect(tx, operationId, "catalog.export.request"); + return { deleted: removed.length, restoreId: Number(restoreId) }; + }, + ); +} + +/** + * Put a deleted selection back. Refuses if any id has since been taken by + * another offer, so a restore can never overwrite unrelated work. + */ +/** + * Recent deletions that can still be taken back, newest first. The undo toast + * is a short window; this is what makes the restore record reachable after it, + * so a delete noticed ten minutes later is not a hand-editing job. + */ +export interface RestorableDeletion { + restoreId: number; + offers: number; + deletedAt: string; + staffId: number; +} + +export async function listRestorableDeletionsCommand( + limit = 20, +): Promise { + const capped = Math.min(Math.max(Math.trunc(limit) || 20, 1), 100); + const rows = rowsFrom<{ + id: number; + userId: number; + before: string | null; + createdAt: string; + }>( + await db.execute( + sql`SELECT id, user_id AS userId, before, created_at AS createdAt FROM ${AdminAuditLog} WHERE action=${DELETE_ACTION} ORDER BY id DESC LIMIT ${capped}`, + ), + ); + const entries: RestorableDeletion[] = []; + for (const row of rows) { + try { + entries.push({ + restoreId: Number(row.id), + offers: parseOffers(row.before).length, + deletedAt: String(row.createdAt ?? ""), + staffId: Number(row.userId), + }); + } catch { + // One corrupt record must not fail the whole list. + } + } + return entries; +} + +export async function restoreDeletedCatalogItemsCommand( + restoreId: number, + userId: number, + requestKey: string = randomUUID(), +) { + if (!Number.isSafeInteger(restoreId) || restoreId < 1) + throw new CatalogInputError("Invalid restore selection"); + return runOperation( + { + actorId: userId, + kind: "catalog.items.restore", + key: requestKey, + input: { restoreId }, + }, + // The lock above is what serialises two restores of the same deletion: + // the audit row is read FOR UPDATE, so the second one sees it already + // gone and stops rather than re-inserting. + async (tx, operationId) => { + const [entry] = await tx + .select() + .from(AdminAuditLog) + .where(sql`${AdminAuditLog.id}=${restoreId} FOR UPDATE`) + .limit(1); + if (!entry || entry.action !== DELETE_ACTION) + throw new CatalogNotFound("This deletion can no longer be restored"); + const offers = parseOffers(entry.before); + const ids = offers.map((offer) => Number(offer.id)); + const taken = rowsFrom<{ id: number }>( + await tx.execute( + sql`SELECT id FROM catalog_items WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id FOR UPDATE`, + ), + ); + if (taken.length) + throw new CatalogNotFound( + "Other offers now use these ids. Restore was not applied.", + ); + // A restored offer pointing at a deleted category would sell nothing and + // would not show up under any page, so the restore has to notice that + // first rather than write an unreachable row. + const pageIds = [ + ...new Set( + offers.map((offer) => Number(offer.pageId)).filter((id) => id > 0), + ), + ].sort((a, b) => a - b); + if (pageIds.length) { + const found = new Set( + rowsFrom<{ id: number }>( + await tx.execute( + sql`SELECT id FROM catalog_pages WHERE id IN (${sql.join(pageIds, sql`, `)}) ORDER BY id FOR UPDATE`, + ), + ).map((row) => Number(row.id)), + ); + const missing = pageIds.filter((id) => !found.has(id)); + if (missing.length) + throw new CatalogNotFound( + `These categories no longer exist, so the offers would be unreachable: ${missing.join(", ")}. Restore was not applied.`, + ); + } + for (const offer of offers) { + await tx.execute( + sql`INSERT INTO catalog_items (${RESTORE_IDS}) VALUES (${sql.join( + RESTORE_COLUMNS.map((key) => sql`${offer[key]}`), + sql`, `, + )})`, + ); + } + // The audit entry has served its purpose once the rows are back. + await tx + .delete(AdminAuditLog) + .where(sql`${AdminAuditLog.id}=${restoreId}`); + await enqueueEffect(tx, operationId, "catalog.refresh"); + await enqueueEffect(tx, operationId, "catalog.export.request"); + return { restored: offers.length }; + }, + ); +} diff --git a/src/features/catalog/server/sync-status.test.ts b/src/features/catalog/server/sync-status.test.ts index bd811c1f..4f671cf0 100644 --- a/src/features/catalog/server/sync-status.test.ts +++ b/src/features/catalog/server/sync-status.test.ts @@ -14,7 +14,11 @@ vi.mock("@/lib/services/catalog-git-config", () => ({ vi.mock("@/lib/services/rcon", () => ({ rcon: { updateCatalog: state.send } })); vi.mock("@/lib/logger", () => ({ logger: { error: state.log } })); -import { readCatalogHotelStatus, sendCatalogUpdate } from "./sync-status"; +import { + readCatalogHotelHistory, + readCatalogHotelStatus, + sendCatalogUpdate, +} from "./sync-status"; beforeEach(async () => { state.root = await fs.mkdtemp(path.join(os.tmpdir(), "catalog-status-")); @@ -40,3 +44,38 @@ it("allows retrying the update without reapplying catalog mutations", async () = }); it("returns no status before the first attempt", async () => expect(await readCatalogHotelStatus()).toBeNull()); + +it("returns no history before the first attempt", async () => + expect(await readCatalogHotelHistory()).toEqual([])); + +it("keeps earlier deliveries instead of overwriting them", async () => { + state.send.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + await sendCatalogUpdate(); + state.log.mockReturnValueOnce("catalog-error-2"); + await sendCatalogUpdate(); + + const history = await readCatalogHotelHistory(); + expect(history.map((entry) => entry.sent)).toEqual([false, true]); + expect(history[0].reference).toBe("catalog-error-2"); + // The snapshot is still the newest write, so the rail keeps showing it. + expect((await readCatalogHotelStatus())?.sent).toBe(false); +}); + +it("survives a corrupt history file without blocking a delivery", async () => { + state.send.mockResolvedValue(true); + await fs.mkdir(state.root, { recursive: true }); + await fs.writeFile( + path.join(state.root, "hotel-status-history.json"), + "{not json", + ); + + await expect(sendCatalogUpdate()).resolves.toMatchObject({ sent: true }); + await expect(readCatalogHotelHistory()).resolves.toHaveLength(1); +}); + +it("drops the oldest entries past the retention limit", async () => { + state.send.mockResolvedValue(true); + for (let i = 0; i < 55; i++) await sendCatalogUpdate(); + + expect(await readCatalogHotelHistory()).toHaveLength(50); +}); diff --git a/src/features/catalog/server/sync-status.ts b/src/features/catalog/server/sync-status.ts index 22edf148..5a202078 100644 --- a/src/features/catalog/server/sync-status.ts +++ b/src/features/catalog/server/sync-status.ts @@ -12,6 +12,11 @@ export interface CatalogHotelStatus { checkedAt: string; reference?: string; } + +/** How many deliveries the history file keeps before dropping the oldest. */ +const HISTORY_LIMIT = 50; +const HISTORY_FILE = "hotel-status-history.json"; + export async function readCatalogHotelStatus(): Promise { try { return JSON.parse( @@ -25,6 +30,61 @@ export async function readCatalogHotelStatus(): Promise { + try { + const parsed: unknown = JSON.parse( + await fs.readFile(path.join(catalogStateRoot(), HISTORY_FILE), "utf8"), + ); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (entry): entry is CatalogHotelStatus => + !!entry && + typeof entry === "object" && + typeof (entry as CatalogHotelStatus).checkedAt === "string" && + typeof (entry as CatalogHotelStatus).sent === "boolean", + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} + +async function appendHotelHistory(status: CatalogHotelStatus): Promise { + const root = catalogStateRoot(); + try { + await fs.mkdir(root, { recursive: true }); + const file = path.join(root, HISTORY_FILE); + let previous: CatalogHotelStatus[] = []; + try { + const parsed: unknown = JSON.parse(await fs.readFile(file, "utf8")); + if (Array.isArray(parsed)) previous = parsed as CatalogHotelStatus[]; + } catch (error) { + // A corrupt history must not stop the delivery itself; the snapshot + // below is still written either way. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") previous = []; + } + const next = [status, ...previous].slice(0, HISTORY_LIMIT); + const temp = path.join(root, `hotel-history-${randomUUID()}.tmp`); + try { + await fs.writeFile(temp, JSON.stringify(next), { flag: "wx" }); + await fs.rename(temp, file); + } finally { + await fs.rm(temp, { force: true }).catch(() => {}); + } + } catch (error) { + logger.error("Cannot append catalog hotel update history", { + module: "catalog", + error, + }); + } +} /** An acknowledged socket write is an update request, not proof of client application. */ export async function sendCatalogUpdate(): Promise { let sent = false; @@ -50,6 +110,7 @@ export async function sendCatalogUpdate(): Promise { // different tab, browser or process than the one watching. await bumpCatalogRevision(); const status = { sent, checkedAt: new Date().toISOString(), reference }; + await appendHotelHistory(status); const root = catalogStateRoot(); const temp = path.join(root, `hotel-${randomUUID()}.tmp`); try { diff --git a/src/features/catalog/server/tree-nodes.test.ts b/src/features/catalog/server/tree-nodes.test.ts new file mode 100644 index 00000000..b26406ed --- /dev/null +++ b/src/features/catalog/server/tree-nodes.test.ts @@ -0,0 +1,295 @@ +import type { SQL } from "drizzle-orm"; +import { int, MySqlDialect, mysqlTable, varchar } from "drizzle-orm/mysql-core"; +import { beforeEach, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + pages: { + normal: [] as Array>, + bc: [] as Array>, + }, + items: { + normal: [] as Array<{ pageId: number }>, + bc: [] as Array<{ pageId: number }>, + }, + queries: [] as string[], + countsShouldThrow: false, +})); + +/** A grouped count is derived in the fake, so `groupBy()` resolves to rows. */ +function groupedCounts( + rows: Array>, + key: string, +): Array<{ parentId: number; total: number }> { + const counts = new Map(); + for (const row of rows) { + const id = Number(row[key]); + counts.set(id, (counts.get(id) ?? 0) + 1); + } + return [...counts].map(([parentId, total]) => ({ parentId, total })); +} + +// Real drizzle tables, so `like()` and `eq()` compile into the same SQL the +// route sends in production and the fake below can filter on it. +const CatalogPages = mysqlTable("catalog_pages", { + id: int("id").primaryKey(), + parentId: int("parent_id"), + caption: varchar("caption", { length: 100 }), + pageLayout: varchar("page_layout", { length: 100 }), + iconColor: int("icon_color"), + iconImage: int("icon_image"), + orderNum: int("order_num"), + visible: varchar("visible", { length: 1 }), + enabled: varchar("enabled", { length: 1 }), +}); +const CatalogPagesBc = mysqlTable("catalog_pages_bc", { + id: int("id").primaryKey(), + parentId: int("parent_id"), + caption: varchar("caption", { length: 100 }), + pageLayout: varchar("page_layout", { length: 100 }), + iconColor: int("icon_color"), + iconImage: int("icon_image"), + orderNum: int("order_num"), + visible: varchar("visible", { length: 1 }), + enabled: varchar("enabled", { length: 1 }), +}); +const CatalogItems = mysqlTable("catalog_items", { + id: int("id").primaryKey(), + pageId: int("page_id"), +}); +const CatalogItemsBc = mysqlTable("catalog_items_bc", { + id: int("id").primaryKey(), + pageId: int("page_id"), +}); + +vi.mock("@/lib/db", async () => { + const tables = { + CatalogPages, + CatalogPagesBc, + CatalogItems, + CatalogItemsBc, + }; + const tableOf = (table: unknown): string => { + const symbol = Symbol.for("drizzle:Name"); + const holder = table as Record; + return holder?.[symbol] ?? ""; + }; + return { + ...tables, + db: { + select: () => ({ + from(table: unknown) { + const name = tableOf(table); + const isBc = name.includes("_bc"); + const rows = isBc ? state.pages.bc : state.pages.normal; + // Real promises, awaited like the query builders they stand in + // for; `.limit()` rides along on the resolved value. + const group = Promise.resolve(groupedCounts(rows, "parentId")); + // An item count groups by page_id over the whole table. + const itemGroup = Promise.resolve( + rows.length + ? state.items[isBc ? "bc" : "normal"].reduce>( + (acc, item) => + acc.set(item.pageId, (acc.get(item.pageId) ?? 0) + 1), + new Map(), + ) + : new Map(), + ).then((map) => + [...map].map(([pageId, total]) => ({ pageId, total })), + ); + const ordered = Object.assign(Promise.resolve(rows), { + limit: async (limit?: number) => + rows.slice(0, limit ?? rows.length), + }); + // `where` filters for real: the compiled text says whether it is a + // caption LIKE, an id match or a parent lookup, and the params + // carry the needle. Mocking it as a no-op would make every + // search test pass for the wrong reason. + const where = (condition: SQL) => { + const { sql: text, params } = new MySqlDialect().sqlToQuery( + condition, + ); + state.queries.push(text); + // A bare `id = ?` (one param) is the parent filter or an id + // search; `id = ? or id = ?` is the two root sentinels. + const matched = rows.filter((row) => { + const parentMatches = (from: number) => + Number(row.parentId) === Number(params[from]); + if (text.includes("caption")) { + const needle = String(params[0] ?? "").replace(/^%|%$/g, ""); + const byCaption = String(row.caption) + .toLowerCase() + .includes(needle.toLowerCase()); + const byId = + params.length > 1 && Number(row.id) === Number(params.at(-1)); + return byCaption || byId; + } + // Two params on the same column is the root sentinel + // OR (-1 / 0), which still filters on parent_id. + if (params.length === 2) + return parentMatches(0) || parentMatches(1); + if (text.includes("parent_id")) return parentMatches(0); + if (params.length === 1) + return Number(row.id) === Number(params[0]); + return true; + }); + const filtered = Object.assign(Promise.resolve(matched), { + limit: async (limit?: number) => + matched.slice(0, limit ?? matched.length), + }); + return { + orderBy: () => filtered, + groupBy: () => group, + limit: filtered.limit, + }; + }; + return { + where, + orderBy: () => ordered, + groupBy: () => itemGroup, + limit: ordered.limit, + }; + }, + }), + }, + queryRows: async () => [], + }; +}); + +vi.mock("@/lib/services/catalog-tree", () => ({ + // The normal catalog keeps its existing helper, including the VARCHAR-safe + // string-literal count; the route must not grow a second count path. + getCatalogItemCounts: async (ids?: number[]) => { + if (state.countsShouldThrow) return new Map(); + const counts = new Map(); + for (const item of state.items.normal) { + if (!ids || ids.includes(item.pageId)) + counts.set(item.pageId, (counts.get(item.pageId) ?? 0) + 1); + } + return counts; + }, + getTreeFlat: async () => [{ id: 1, caption: "Root", parentId: -1, depth: 0 }], +})); + +const { readChildren, searchPages, readFullTree } = await import( + "./tree-nodes" +); + +beforeEach(() => { + state.queries = []; + state.countsShouldThrow = false; + state.pages.normal = [ + { + id: 1, + parentId: -1, + caption: "Root", + orderNum: 0, + enabled: "1", + visible: "1", + }, + { + id: 2, + parentId: 1, + caption: "Child", + orderNum: 0, + enabled: "1", + visible: "1", + }, + ]; + state.pages.bc = [ + { + id: 10, + parentId: 0, + caption: "BC Root", + orderNum: 0, + enabled: "1", + visible: "1", + }, + { + id: 11, + parentId: 10, + caption: "BC Child", + orderNum: 0, + enabled: "1", + visible: "1", + }, + ]; + state.items.normal = [{ pageId: 1 }, { pageId: 1 }, { pageId: 2 }]; + state.items.bc = [{ pageId: 10 }]; +}); + +it("shapes BC children with the same counts as the normal catalog", async () => { + const children = await readChildren("bc", 10); + + expect(children).toHaveLength(1); + expect(children[0]).toMatchObject({ + id: 11, + caption: "BC Child", + childCount: 0, + itemCount: 0, + depth: 0, + }); +}); + +it("treats both root sentinels as roots", async () => { + // BC installs use parent_id = 0, normal ones -1; neither is a real parent. + expect((await readChildren("bc", 0)).map((n) => n.id)).toEqual([10]); + expect((await readChildren("normal", -1)).map((n) => n.id)).toEqual([1]); +}); + +it("counts offers per page for both catalogs", async () => { + const normal = await readChildren("normal", -1); + expect(normal.find((n) => n.id === 1)?.itemCount).toBe(2); +}); + +it("keeps serving the tree when a count fails", async () => { + state.countsShouldThrow = true; + const children = await readChildren("normal", -1); + expect(children.length).toBeGreaterThan(0); + expect(children.every((n) => n.itemCount === 0)).toBe(true); +}); + +it("finds a category by caption and by exact id in both catalogs", async () => { + expect((await searchPages("bc", "BC Child")).map((n) => n.id)).toEqual([11]); + // A bare number is an id lookup, and must not fall back to a caption match. + expect((await searchPages("normal", "2")).map((n) => n.id)).toEqual([2]); + expect(await searchPages("normal", "999")).toEqual([]); +}); + +it("reads the whole normal tree through the same shaping as BC", async () => { + const tree = await readFullTree("normal"); + expect(tree.map((n) => [n.id, n.depth, n.itemCount])).toEqual([ + [1, 0, 2], + [2, 1, 1], + ]); +}); + +it("resolves depth for a BC tree in one read", async () => { + const tree = await readFullTree("bc"); + expect(tree.map((n) => [n.id, n.depth])).toEqual([ + [10, 0], + [11, 1], + ]); +}); + +it("does not hang on a parent cycle", async () => { + state.pages.bc = [ + { + id: 20, + parentId: 21, + caption: "A", + orderNum: 0, + enabled: "1", + visible: "1", + }, + { + id: 21, + parentId: 20, + caption: "B", + orderNum: 1, + enabled: "1", + visible: "1", + }, + ]; + const tree = await readFullTree("bc"); + expect(tree.every((n) => Number.isFinite(n.depth))).toBe(true); +}); diff --git a/src/features/catalog/server/tree-nodes.ts b/src/features/catalog/server/tree-nodes.ts new file mode 100644 index 00000000..0d9d3d85 --- /dev/null +++ b/src/features/catalog/server/tree-nodes.ts @@ -0,0 +1,273 @@ +import "server-only"; +import { asc, count, eq, inArray, like, or, type SQL } from "drizzle-orm"; +import { + CatalogItems, + CatalogItemsBc, + CatalogPages, + CatalogPagesBc, + db, +} from "@/lib/db"; +import { getCatalogItemCounts } from "@/lib/services/catalog-tree"; +import type { TreeNode } from "@/types/catalog"; + +export type CatalogKind = "normal" | "bc"; + +export function toInt(value: unknown, fallback = 0): number { + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) ? n : fallback; +} + +/** The nine columns every catalog tree read needs, in one shape. */ +const PAGE_COLUMNS = [ + "id", + "parentId", + "caption", + "pageLayout", + "iconColor", + "iconImage", + "orderNum", + "visible", + "enabled", +] as const; + +type PageRow = { + id: number | string; + parentId: number | string; + caption: string; + pageLayout: string | null; + iconColor: number | string | null; + iconImage: number | string | null; + orderNum: number | string | null; + visible: string | number | null; + enabled: string | number | null; +}; + +function tables(kind: CatalogKind) { + return kind === "bc" + ? { pages: CatalogPagesBc, items: CatalogItemsBc } + : { pages: CatalogPages, items: CatalogItems }; +} + +/** + * One shaping function for every tree read. The normal and BC catalogs have the + * same columns and the same child/item counts; the earlier copies of this logic + * had already drifted apart (one counted items through `getCatalogItemCounts` + * with its error tolerance, the other inline and swallowed), so the divergence + * was a matter of time. + */ +async function readPages( + kind: CatalogKind, + where: SQL | undefined, + limit?: number, +): Promise { + const { pages, items } = tables(kind); + const columns = { + id: pages.id, + parentId: pages.parentId, + caption: pages.caption, + pageLayout: pages.pageLayout, + iconColor: pages.iconColor, + iconImage: pages.iconImage, + orderNum: pages.orderNum, + visible: pages.visible, + enabled: pages.enabled, + }; + const rows = await db + .select(columns) + .from(pages) + .where(where) + .orderBy(asc(pages.orderNum), asc(pages.id)) + .limit(limit ?? 50); + const ids = rows.map((row) => toInt(row.id)); + if (!ids.length) return []; + + const [childCounts, itemCounts] = await Promise.all([ + db + .select({ parentId: pages.parentId, total: count() }) + .from(pages) + .where(inArray(pages.parentId, ids)) + .groupBy(pages.parentId), + // Normal counts through the shared helper because real Habbo DBs store + // page_id as VARCHAR; BC uses the typed column and does not need it. + kind === "bc" + ? db + .select({ pageId: items.pageId, total: count() }) + .from(items) + .where(inArray(items.pageId, ids)) + .groupBy(items.pageId) + .catch(() => []) + : getCatalogItemCounts(ids).then( + (map) => + [...map].map(([pageId, total]) => ({ pageId, total })) as { + pageId: number; + total: number; + }[], + ), + ]); + const childMap = new Map( + childCounts.map((row) => [toInt(row.parentId), Number(row.total)]), + ); + const itemMap = new Map( + itemCounts.map((row) => [toInt(row.pageId), Number(row.total)]), + ); + return rows.map((row) => toTreeNode(row, childMap, itemMap)); +} + +function toTreeNode( + row: PageRow, + childMap: Map, + itemMap: Map, +): TreeNode { + const id = toInt(row.id); + return { + id, + caption: row.caption, + parentId: toInt(row.parentId, -1), + depth: 0, + orderNum: toInt(row.orderNum), + enabled: String(row.enabled ?? "0"), + visible: String(row.visible ?? "0"), + iconImage: toInt(row.iconImage), + iconColor: toInt(row.iconColor), + pageLayout: row.pageLayout ?? "", + childCount: childMap.get(id) ?? 0, + itemCount: itemMap.get(id) ?? 0, + }; +} + +/** Roots differ between Habbo installs, so both sentinels are treated as roots. */ +export function rootFilter(kind: CatalogKind, parentId: number) { + const { pages } = tables(kind); + return parentId <= 0 + ? or(eq(pages.parentId, -1), eq(pages.parentId, 0)) + : eq(pages.parentId, parentId); +} + +export function readChildren(kind: CatalogKind, parentId: number) { + return readPages(kind, rootFilter(kind, parentId)); +} + +export function searchPages(kind: CatalogKind, query: string) { + const { pages } = tables(kind); + const needle = query.trim(); + const exactId = Number.parseInt(needle, 10); + const byId = Number.isFinite(exactId) && String(exactId) === needle; + const conditions = [like(pages.caption, `%${needle}%`)]; + if (byId) conditions.push(eq(pages.id, exactId)); + return readPages(kind, or(...conditions), 50); +} + +/** + * The whole catalog in one read, with depth resolved. Depth needs the full set + * in memory — a per-page walk would be one query per level, and the tree is the + * one payload that grows with the catalog. + * + * Both catalogs go through the same shaping. An earlier version delegated the + * normal catalog to `getTreeFlat` while computing BC here, which left two + * implementations of the same output behind one function name. + */ +export async function readFullTree(kind: CatalogKind): Promise { + return readFlatFromMemory( + await readAllPages(kind), + await readAllItemCounts(kind), + ); +} + +async function readAllItemCounts( + kind: CatalogKind, +): Promise> { + // Normal goes through the shared helper: real Habbo DBs store page_id as + // VARCHAR, and only the string-literal form keeps that index usable. + if (kind === "normal") return getCatalogItemCounts(); + const { items } = tables(kind); + const rows = await db + .select({ pageId: items.pageId, total: count() }) + .from(items) + .groupBy(items.pageId) + .catch(() => []); + return new Map(rows.map((row) => [toInt(row.pageId), Number(row.total)])); +} + +async function readAllPages(kind: CatalogKind): Promise { + const { pages } = tables(kind); + return db + .select({ + id: pages.id, + parentId: pages.parentId, + caption: pages.caption, + pageLayout: pages.pageLayout, + iconColor: pages.iconColor, + iconImage: pages.iconImage, + orderNum: pages.orderNum, + visible: pages.visible, + enabled: pages.enabled, + }) + .from(pages) + .orderBy(asc(pages.orderNum), asc(pages.id)) as Promise; +} + +function readFlatFromMemory( + rows: PageRow[], + itemCounts: Map, +): TreeNode[] { + const childCounts = new Map(); + for (const row of rows) { + const parentId = toInt(row.parentId, -1); + childCounts.set(parentId, (childCounts.get(parentId) ?? 0) + 1); + } + const pageMap = new Map(rows.map((row) => [toInt(row.id), row])); + const depths = new Map(); + + function depth(id: number, visiting = new Set()): number { + const cached = depths.get(id); + if (cached !== undefined) return cached; + // Messy imports can leave a parent chain looping; stop rather than recurse. + if (visiting.has(id)) return 0; + visiting.add(id); + const parentId = pageMap.has(id) + ? toInt(pageMap.get(id)?.parentId, -1) + : -1; + const value = + !parentId || parentId <= 0 ? 0 : depth(parentId, visiting) + 1; + visiting.delete(id); + depths.set(id, value); + return value; + } + + return rows.map((row) => { + const node = toTreeNode(row, childCounts, itemCounts); + return { ...node, depth: depth(node.id) }; + }); +} + +export async function readPage(kind: CatalogKind, id: number) { + const { pages } = tables(kind); + const [page] = await db.select().from(pages).where(eq(pages.id, id)).limit(1); + return page; +} + +export async function readAncestors(kind: CatalogKind, id: number) { + const { pages } = tables(kind); + const chain: Array<{ id: number; caption: string }> = []; + let currentId = id; + // 50 levels, matching `getAncestors`. The old BC branch stopped at 20, which + // truncated the breadcrumb on a deeply nested catalog. + for (let i = 0; i < 50; i++) { + const [page] = await db + .select({ + id: pages.id, + caption: pages.caption, + parentId: pages.parentId, + }) + .from(pages) + .where(eq(pages.id, currentId)) + .limit(1); + if (!page) break; + chain.unshift({ id: toInt(page.id), caption: page.caption }); + if (toInt(page.parentId, -1) <= 0) break; + currentId = toInt(page.parentId); + } + return chain; +} + +export const CATALOG_PAGE_COLUMNS = PAGE_COLUMNS; diff --git a/src/features/housekeeping/migration/hotel.ts b/src/features/housekeeping/migration/hotel.ts index 42ad0b91..33dd2b3b 100644 --- a/src/features/housekeeping/migration/hotel.ts +++ b/src/features/housekeeping/migration/hotel.ts @@ -524,7 +524,7 @@ export const hotelMigrationEntries: readonly MigrationEntry[] = [ "GET /api/admin/import/clone", "GET /api/admin/import/furni", "getHabboGamedataHotel", - "getTreeFlat", + "readFullTree", "previewAutoCatalog", "siteSettings.getBool", ], diff --git a/src/lib/services/catalog-tree.ts b/src/lib/services/catalog-tree.ts index 1e136eda..09a03cf3 100644 --- a/src/lib/services/catalog-tree.ts +++ b/src/lib/services/catalog-tree.ts @@ -1,4 +1,4 @@ -import { asc, eq, sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { deletePageCommand, updatePageCommand, @@ -59,76 +59,6 @@ export async function getCatalogItemCounts( return map; } -/** - * Get the full catalog tree as a flat list with depth info. - */ -export async function getTreeFlat(): Promise { - const allPages = await db - .select({ - id: CatalogPages.id, - parentId: CatalogPages.parentId, - caption: CatalogPages.caption, - pageLayout: CatalogPages.pageLayout, - iconColor: CatalogPages.iconColor, - iconImage: CatalogPages.iconImage, - orderNum: CatalogPages.orderNum, - visible: CatalogPages.visible, - enabled: CatalogPages.enabled, - }) - .from(CatalogPages) - .orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id)); - const itemCountMap = await getCatalogItemCounts(); - - const childCountMap = new Map(); - 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(); - - function computeDepth(pageId: number, visiting = new Set()): 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. */