feat(catalog): record bulk edits, make deletions reversible, unify the tree read
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m43s
CI / tests-unit (push) Successful in 1m48s
CI / tests-ui (push) Successful in 2m37s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m23s

A bulk offer edit is the catalog mutation that rewrites hundreds of rows at
once, and it was the only one writing nothing to the staff activity log: 22 of
the 43 catalog actions logged, this one did not. The entry it now writes says
what changed, not just that something did, because the log has no undo of its
own and "bulk updated 200 offers" cannot answer the question it exists for.

Deleting offers had no inverse at all. Every removed row is now kept at delete
time and the caller gets a restoreId back, so an accidental multi-select is a
click rather than a hand-edit of the table. The undo toast covers the common
case; a RecentDeletionsPanel holds the same records so a delete noticed later is
still reachable. Three refusals guard it: an id that another offer has since
taken, a category that no longer exists (which would leave an offer that sells
nowhere and shows under no page), and a delete whose restore record cannot be
written — that one rolls back rather than deleting without a way back. Reading
the audit row FOR UPDATE is also what stops two restores of one deletion from
both inserting.

sendCatalogUpdate() overwrote hotel-status.json on every write, so "which
imports reached the hotel" was answerable for the last attempt only, and a
failure two imports ago was gone by the time anyone looked. That file is now
also appended to as a bounded 50-entry tail.

The tree route carried four copies of the same page-select-plus-counts
shaping, of which the BC branches had already drifted: one counted offers
through the VARCHAR-tolerant helper, the other inline and swallowing errors.
All of it is one readPages() now, and readFullTree sends both catalogs through
one depth computation instead of delegating normal to getTreeFlat while
computing BC here — a split that left two implementations behind one function
name. getTreeFlat is gone. The BC ancestor walk also went from 20 levels to 50,
matching getAncestors, so a deeply nested catalog no longer loses its
breadcrumb.

Bulk editing reaches the BC catalog, which previously had no way to edit or
duplicate offers in bulk. The catalog is part of the operation identity now, so
replaying one request key against the other catalog is not mistaken for the
same work.

Integration tests failed to import: the next/cache mock supplied only
revalidatePath, and catalog-totals calls unstable_cache at module scope.
This commit is contained in:
openhands committed 2026-09-30 18:19:36 +02:00
1 parent 9550b3d66f
commit cebcf440c5
20 files changed
+1780 -494

No files matched your search

+68 -4
View File
@@ -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<keyof BulkOfferInput["changes"], string> = {
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 };
});
+86 -12
View File
@@ -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({
+4 -2
View File
@@ -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() }),
+19 -381
View File
@@ -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<TreeNode[]> {
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<number, number>();
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<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, 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<TreeNode[]> {
// 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<TreeNode[]> {
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);
@@ -830,6 +830,7 @@ function InlineEditorSession({
interactionTypes={itemsData.interactionTypes}
furniRevisionMap={itemsData.furniRevisionMap}
allPages={itemsData.allPages}
catalog={catalogType}
/>
</div>
) : null}
@@ -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<number, { classname: string; revision: number }>;
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<CatalogItemData | null>(null);
const [editingBase, setEditingBase] = useState<BaseItemData | null>(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 && (
<details className="mb-3">
<summary className="cursor-pointer text-sm text-[var(--admin-text-muted)]">
Recent deletions
</summary>
<div className="mt-2">
<RecentDeletionsPanel onRestored={onRefresh} />
</div>
</details>
)}
{bulkRefreshFrom !== null && (
<p
role="status"
@@ -875,6 +925,7 @@ export function CatalogItemsTable({
<BulkOfferEditor
ids={[...selected]}
allPages={allPages}
catalog={catalog}
disabled={
hasUnsavedChanges ||
isPending ||
@@ -0,0 +1,134 @@
"use client";
import { Loader2, RotateCcw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { listRestorableCatalogItemDeletions } from "@/actions/catalog-items";
import { Button } from "@/components/ui/button";
interface RestorableDeletion {
restoreId: number;
offers: number;
deletedAt: string;
}
/**
* The undo toast only lives a few seconds. A delete noticed after that window is
* still recoverable — the rows are kept until they are restored — so the offer
* has to be reachable here too. Without this panel the only way back into a
* past deletion is editing the database by hand.
*/
export function RecentDeletionsPanel({
onRestored,
}: {
onRestored: () => void;
}) {
const [entries, setEntries] = useState<RestorableDeletion[]>([]);
const [loading, setLoading] = useState(true);
const [restoring, setRestoring] = useState<number | null>(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 (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
Loading recent deletions…
</p>
);
}
if (failed) {
return (
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--admin-error)]">
Could not load recent deletions.
</span>
<Button variant="ghost" size="sm" onClick={() => void load()}>
Retry
</Button>
</div>
);
}
if (!entries.length) return null;
return (
<div className="rounded-md border border-[var(--admin-border)] p-2">
<p className="mb-1.5 text-xs font-medium text-[var(--admin-text-muted)]">
Recent deletions — restorable
</p>
<ul className="space-y-1">
{entries.map((entry) => (
<li
key={entry.restoreId}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="min-w-0 truncate">
{entry.offers} offer(s) ·{" "}
<time dateTime={entry.deletedAt}>
{new Date(entry.deletedAt).toLocaleString()}
</time>
</span>
<Button
variant="outline"
size="sm"
disabled={restoring !== null}
onClick={() => void restore(entry)}
>
{restoring === entry.restoreId && (
<RotateCcw aria-hidden className="mr-2 h-4 w-4 animate-spin" />
)}
Restore
</Button>
</li>
))}
</ul>
</div>
);
}
@@ -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<void>;
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);
@@ -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"
/>
</div>
{canEdit && catalogType === "normal" && selected.size > 0 && (
{canEdit && selected.size > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-md border p-2">
<span className="text-sm">
{bulk("selectedCount", { count: selected.size })}
@@ -168,6 +168,7 @@ export function CatalogSearch({
<BulkOfferEditor
ids={[...selected]}
allPages={pages}
catalog={catalogType}
beforeEdit={mayEdit}
onApplied={() => {
setSelected(new Set());
@@ -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();
});
+33 -9
View File
@@ -23,15 +23,30 @@ import {
} from "../domain/hierarchy";
type Tx = Parameters<Parameters<typeof db.transaction>[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<OfferRow[]> {
const offers = rowsFrom<OfferRow>(
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<BulkOfferPreview> {
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)
@@ -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<unknown>) => {
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<string, unknown>) => {
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<unknown>,
) => (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]);
});
+278
View File
@@ -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<string, unknown>;
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<DeletedOffer>(
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<RestorableDeletion[]> {
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 };
},
);
}
@@ -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);
});
@@ -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<CatalogHotelStatus | null> {
try {
return JSON.parse(
@@ -25,6 +30,61 @@ export async function readCatalogHotelStatus(): Promise<CatalogHotelStatus | nul
throw error;
}
}
/**
* The last delivery is not the same question as "which imports reached the
* hotel". `hotel-status.json` answers the first and overwrites itself on every
* write, so a failure two imports ago is gone by the time anyone looks. This
* keeps a bounded tail instead, newest first.
*/
export async function readCatalogHotelHistory(): Promise<CatalogHotelStatus[]> {
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<void> {
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<CatalogHotelStatus> {
let sent = false;
@@ -50,6 +110,7 @@ export async function sendCatalogUpdate(): Promise<CatalogHotelStatus> {
// 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 {
@@ -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<Record<string, unknown>>,
bc: [] as Array<Record<string, unknown>>,
},
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<Record<string, unknown>>,
key: string,
): Array<{ parentId: number; total: number }> {
const counts = new Map<number, number>();
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<symbol, string>;
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<Map<number, number>>(
(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<number, number>();
const counts = new Map<number, number>();
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);
});
+273
View File
@@ -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<TreeNode[]> {
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<number, number>,
itemMap: Map<number, number>,
): 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<TreeNode[]> {
return readFlatFromMemory(
await readAllPages(kind),
await readAllItemCounts(kind),
);
}
async function readAllItemCounts(
kind: CatalogKind,
): Promise<Map<number, number>> {
// 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<PageRow[]> {
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<PageRow[]>;
}
function readFlatFromMemory(
rows: PageRow[],
itemCounts: Map<number, number>,
): TreeNode[] {
const childCounts = new Map<number, number>();
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<number, number>();
function depth(id: number, visiting = new Set<number>()): 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;
+1 -1
View File
@@ -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",
],
+1 -71
View File
@@ -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<TreeNode[]> {
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<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.
*/