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

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

No files matched your search

+60 -118
View File
@@ -1,12 +1,24 @@
"use server";
import { eq, inArray } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { catalogFailure } from "@/features/catalog/server/errors";
import {
createBcOfferCommand,
updateBcOfferCommand,
} from "@/features/catalog/server/offer-commands";
import {
createPageCommand,
deletePageCommand,
reorderPagesCommand,
togglePageCommand,
updatePageCommand,
} from "@/features/catalog/server/page-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
import { CatalogItemsBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const BC_PAGE_FIELDS = [
@@ -50,19 +62,24 @@ function pickAllowed(
export async function updateBcPage({
id,
expected,
...fields
}: { id: number } & Record<string, unknown>) {
}: { id: number; expected?: Record<string, unknown> } & Record<
string,
unknown
>) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const data = pickAllowed(fields, BC_PAGE_FIELDS);
if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
await db
.update(CatalogPagesBc)
.set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
.where(eq(CatalogPagesBc.id, id));
await rcon.updateCatalog();
try {
await updatePageCommand("bc", id, data, expected);
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "bc_page_update",
@@ -79,7 +96,7 @@ export async function deleteBcItem({ id }: { id: number }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
await rcon.updateCatalog();
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_delete",
@@ -108,11 +125,12 @@ export async function updateBcItem({
if (Object.keys(safe).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
await db
.update(CatalogItemsBc)
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
.where(eq(CatalogItemsBc.id, id));
await rcon.updateCatalog();
try {
await updateBcOfferCommand(id, safe);
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_update",
@@ -137,11 +155,8 @@ export async function createBcItem({
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const [result] = await db
.insert(CatalogItemsBc)
.values({ pageId, ...data });
const createdId = Number(result.insertId);
await rcon.updateCatalog();
const createdId = await createBcOfferCommand({ pageId, ...data });
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_create",
@@ -163,20 +178,8 @@ export async function toggleBcPage({
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const [page] = await db
.select({
enabled: CatalogPagesBc.enabled,
visible: CatalogPagesBc.visible,
})
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, id))
.limit(1);
if (!page) return { ok: false as const, error: "Page not found" };
await db
.update(CatalogPagesBc)
.set({ [field]: page[field] === "1" ? "0" : "1" })
.where(eq(CatalogPagesBc.id, id));
await rcon.updateCatalog();
await togglePageCommand("bc", id, field);
await sendCatalogUpdate();
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
});
@@ -194,7 +197,7 @@ export async function createBcPage(input: {
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const [result] = await db.insert(CatalogPagesBc).values({
const createdId = await createPageCommand("bc", {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
@@ -206,8 +209,7 @@ export async function createBcPage(input: {
pageHeadline: "",
pageTeaser: "",
});
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "bc_page_create",
@@ -221,28 +223,6 @@ export async function createBcPage(input: {
});
}
async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
if (newParentId > 0) {
let currentId = newParentId;
for (let i = 0; i < 50; i++) {
if (currentId === pageId) {
throw new Error("Cannot move page: would create a circular hierarchy");
}
const [parent] = await db
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, currentId))
.limit(1);
if (!parent || parent.parentId <= 0) break;
currentId = parent.parentId;
}
}
await db
.update(CatalogPagesBc)
.set({ parentId: newParentId })
.where(eq(CatalogPagesBc.id, pageId));
}
export async function reorderBcTreePage(input: {
pageId: number;
newParentId?: number;
@@ -250,21 +230,11 @@ export async function reorderBcTreePage(input: {
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
if (input.newParentId !== undefined) {
try {
await moveBcPage(input.pageId, input.newParentId);
} catch (err) {
return {
ok: false as const,
error: err instanceof Error ? err.message : "Invalid move",
};
}
}
await db
.update(CatalogPagesBc)
.set({ orderNum: input.newOrderNum })
.where(eq(CatalogPagesBc.id, input.pageId));
await rcon.updateCatalog();
await updatePageCommand("bc", input.pageId, {
parentId: input.newParentId,
orderNum: input.newOrderNum,
});
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
@@ -277,51 +247,23 @@ export async function deleteBcTreePage(input: {
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const [page] = await db
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, input.pageId))
.limit(1);
if (!page) return { ok: false as const, error: "Page not found" };
await deletePageCommand("bc", input.pageId, input.mode);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
});
}
if (input.mode === "reparent") {
await db.transaction(async (tx) => {
await tx
.update(CatalogPagesBc)
.set({ parentId: page.parentId })
.where(eq(CatalogPagesBc.parentId, input.pageId));
await tx
.delete(CatalogItemsBc)
.where(eq(CatalogItemsBc.pageId, input.pageId));
await tx
.delete(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, input.pageId));
});
} else {
const toDelete: number[] = [input.pageId];
const queue: number[] = [input.pageId];
while (queue.length > 0) {
const children = await db
.select({ id: CatalogPagesBc.id })
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, queue));
queue.length = 0;
for (const child of children) {
toDelete.push(child.id);
queue.push(child.id);
}
}
await db.transaction(async (tx) => {
await tx
.delete(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, toDelete));
await tx
.delete(CatalogPagesBc)
.where(inArray(CatalogPagesBc.id, toDelete));
});
}
await rcon.updateCatalog();
export async function reorderBcCatalogPages(input: {
parentId: number;
ids: number[];
expectedIds: number[];
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return withCatalogExport(async () => {
await reorderPagesCommand("bc", input);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
+34 -112
View File
@@ -2,72 +2,23 @@
import { eq, inArray, like, or, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { offerPatchSchema } from "@/features/catalog/domain/offer-input";
import {
createOfferCommand,
moveOffersCommand,
reorderOffersCommand,
updateOfferCommand,
} from "@/features/catalog/server/offer-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogItems, db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { translateItemsSchema } from "@/lib/validators/catalog";
const CATALOG_ITEM_FIELDS = [
"pageId",
"itemIds",
"catalogName",
"costCredits",
"costPoints",
"pointsType",
"amount",
"orderNumber",
"offerId",
"songId",
"limitedSells",
"limitedStack",
"extradata",
"haveOffer",
"clubOnly",
] as const;
const ITEMS_BASE_FIELDS = [
"publicName",
"itemName",
"type",
"width",
"length",
"stackHeight",
"allowStack",
"allowSit",
"allowLay",
"allowWalk",
"allowGift",
"allowTrade",
"allowRecycle",
"allowMarketplaceSell",
"allowInventoryStack",
"interactionType",
"interactionModesCount",
"vendingIds",
"customparams",
"effectIdMale",
"effectIdFemale",
"clothingOnWalk",
] as const;
function pickAllowed(
fields: Record<string, unknown>,
allowed: readonly string[],
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of allowed) {
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
out[key] = fields[key];
}
}
return out;
}
/** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */
async function insertCatalogItemRow(data: {
pageId: number;
@@ -87,8 +38,10 @@ async function insertCatalogItemRow(data: {
clubOnly: string;
}): Promise<number> {
const pageIdStr = String(data.pageId);
return allocateCatalogItemId(async (nextId) => {
await db.execute(sql`
offerPatchSchema.required().parse(data);
return allocateCatalogItemId((nextId) =>
createOfferCommand("normal", data, async (tx) => {
await tx.execute(sql`
INSERT INTO catalog_items (
id, page_id, item_ids, catalog_name,
cost_credits, cost_points, points_type, amount,
@@ -102,8 +55,9 @@ async function insertCatalogItemRow(data: {
${data.haveOffer}, ${data.clubOnly}
)
`);
return nextId;
});
return nextId;
}),
);
}
export async function createCatalogItem(data: {
@@ -141,7 +95,7 @@ export async function createCatalogItem(data: {
}
}
const id = await insertCatalogItemRow({ ...data, catalogName });
await rcon.updateCatalog();
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_item_create",
@@ -221,7 +175,7 @@ export async function bulkCreateCatalogItems({
}
if (created > 0) {
await rcon.updateCatalog();
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_bulk_create",
@@ -240,7 +194,7 @@ export async function deleteCatalogItems({ ids }: { ids: number[] }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
await rcon.updateCatalog();
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_delete",
@@ -264,16 +218,8 @@ export async function moveCatalogItems({
if (ids.length === 0) {
return { ok: true as const, data: {} };
}
const pageIdStr = String(targetPageId);
await db.execute(sql`
UPDATE catalog_items
SET page_id = ${pageIdStr}
WHERE id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})
`);
await rcon.updateCatalog();
await moveOffersCommand(ids, targetPageId);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
@@ -286,13 +232,8 @@ export async function reorderCatalogItems({
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
for (const { id, orderNumber } of orders) {
await db
.update(CatalogItems)
.set({ orderNumber })
.where(eq(CatalogItems.id, id));
}
await rcon.updateCatalog();
await reorderOffersCommand(orders);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
@@ -309,37 +250,18 @@ export async function updateCatalogItem({
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS);
if (Object.keys(safeCatalog).length === 0 && !baseItem) {
return { ok: false as const, error: "No valid fields to update" };
try {
await updateOfferCommand({ id, catalogFields, baseItem });
} catch (error) {
return {
ok: false as const,
error:
error instanceof Error
? error.message
: "Unable to update catalog item",
};
}
// page_id is often VARCHAR — update it via raw SQL when present.
const pageIdRaw = safeCatalog.pageId;
if (pageIdRaw !== undefined) {
const pageIdStr = String(pageIdRaw);
await db.execute(sql`
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
`);
delete safeCatalog.pageId;
}
if (Object.keys(safeCatalog).length > 0) {
await db
.update(CatalogItems)
.set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
.where(eq(CatalogItems.id, id));
}
if (baseItem) {
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
if (Object.keys(safeBase).length > 0) {
await db
.update(ItemsBase)
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
.where(eq(ItemsBase.id, baseItem.id));
}
}
await rcon.updateCatalog();
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_item_update",
@@ -453,7 +375,7 @@ export async function translateCatalogItems(input: {
invalidateFurniDataCache();
}
await rcon.updateCatalog();
await sendCatalogUpdate();
await logAudit({
userId: staff.id,
action: "items_base_translate",
+51 -48
View File
@@ -1,14 +1,19 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { catalogFailure } from "@/features/catalog/server/errors";
import {
createPageCommand,
deletePageCommand,
reorderPagesCommand,
togglePageCommand,
updatePageCommand,
} from "@/features/catalog/server/page-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogPages, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import type { ActionResult } from "@/lib/safe-action-shared";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { deletePage, movePage } from "@/lib/services/catalog-tree";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const CATALOG_PAGE_FIELDS = [
@@ -46,8 +51,12 @@ function pickPageFields(fields: Record<string, unknown>) {
export async function updateCatalogPage({
id,
expected,
...fields
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
}: { id: number; expected?: Record<string, unknown> } & Record<
string,
unknown
>): Promise<ActionResult> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const data = pickPageFields(fields);
@@ -57,11 +66,12 @@ export async function updateCatalogPage({
if (typeof data.caption === "string" && !data.captionSave) {
data.captionSave = data.caption.slice(0, 25);
}
await db
.update(CatalogPages)
.set(data as Partial<typeof CatalogPages.$inferInsert>)
.where(eq(CatalogPages.id, id));
await rcon.updateCatalog();
try {
await updatePageCommand("normal", id, data, expected);
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_update",
@@ -77,8 +87,8 @@ export async function updateCatalogPage({
export async function deleteCatalogPage({ id }: { id: number }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await deletePage(id, "reparent");
await rcon.updateCatalog();
await deletePageCommand("normal", id, "reparent");
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_delete",
@@ -100,22 +110,12 @@ export async function toggleCatalogPage({
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const [page] = await db
.select({
enabled: CatalogPages.enabled,
visible: CatalogPages.visible,
})
.from(CatalogPages)
.where(eq(CatalogPages.id, id))
.limit(1);
if (!page) return { ok: false as const, error: "Catalog page not found" };
const field = action === "toggleEnabled" ? "enabled" : "visible";
const current = action === "toggleEnabled" ? page.enabled : page.visible;
await db
.update(CatalogPages)
.set({ [field]: current === "1" ? "0" : "1" })
.where(eq(CatalogPages.id, id));
await rcon.updateCatalog();
await togglePageCommand(
"normal",
id,
action === "toggleEnabled" ? "enabled" : "visible",
);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
@@ -134,7 +134,7 @@ export async function createCatalogPage(input: {
}): Promise<ActionResult<{ id: number }>> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const [result] = await db.insert(CatalogPages).values({
const createdId = await createPageCommand("normal", {
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
@@ -151,8 +151,7 @@ export async function createCatalogPage(input: {
pageTeaser: "",
includes: "",
});
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_create",
@@ -172,21 +171,11 @@ export async function reorderTreePage(input: {
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
if (input.newParentId !== undefined) {
try {
await movePage(input.pageId, input.newParentId);
} catch (err) {
return {
ok: false as const,
error: err instanceof Error ? err.message : "Invalid move",
};
}
}
await db
.update(CatalogPages)
.set({ orderNum: input.newOrderNum })
.where(eq(CatalogPages.id, input.pageId));
await rcon.updateCatalog();
await updatePageCommand("normal", input.pageId, {
parentId: input.newParentId,
orderNum: input.newOrderNum,
});
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
@@ -198,8 +187,22 @@ export async function deleteTreePage(input: {
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await deletePage(input.pageId, input.mode);
await rcon.updateCatalog();
await deletePageCommand("normal", input.pageId, input.mode);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
}
export async function reorderCatalogPages(input: {
parentId: number;
ids: number[];
expectedIds: number[];
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return withCatalogExport(async () => {
await reorderPagesCommand("normal", input);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
@@ -144,6 +144,8 @@ function SortableRow({
}
interface CatalogItemsTableProps {
onDirtyChange?: (dirty: boolean) => void;
onRefresh?: () => void;
items: CatalogItemData[];
pageId: number;
pageLayout: string;
@@ -154,6 +156,8 @@ interface CatalogItemsTableProps {
}
export function CatalogItemsTable({
onDirtyChange,
onRefresh,
items,
pageId,
pageLayout,
@@ -291,6 +295,9 @@ export function CatalogItemsTable({
}, [items, itemOrder]);
const hasUnsavedChanges = modifiedItems.length > 0 || hasOrderChanges;
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onDirtyChange]);
// Warn before navigating away with unsaved changes
useEffect(() => {
@@ -383,7 +390,8 @@ export function CatalogItemsTable({
errorMessage: `Failed to save item #${item.id}.`,
onSuccess: () => {
setSavingItemId(null);
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
onError: () => setSavingItemId(null),
},
@@ -426,6 +434,10 @@ export function CatalogItemsTable({
else failedCount++;
}
if (savedCount > 0) {
if (onRefresh) onRefresh();
else router.refresh();
}
if (failedCount > 0) {
return {
ok: false as const,
@@ -456,7 +468,8 @@ export function CatalogItemsTable({
errorMessage: "Failed to delete items.",
onSuccess: () => {
setSelected(new Set());
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
});
}
@@ -490,7 +503,8 @@ export function CatalogItemsTable({
setSelected(new Set());
setMoveOpen(false);
setMoveTargetPageId(null);
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
},
);
@@ -590,7 +604,8 @@ export function CatalogItemsTable({
onSuccess: () => {
setAddOpen(false);
setNewItem({ ...DEFAULT_NEW_ITEM });
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
},
);
@@ -641,7 +656,8 @@ export function CatalogItemsTable({
onSuccess: () => {
setMoveOneId(null);
setMoveOneTarget(null);
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
},
);
@@ -711,7 +727,8 @@ export function CatalogItemsTable({
onSuccess: () => {
setEditingItem(null);
setEditingBase(null);
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
},
);
@@ -136,10 +136,18 @@ export function CatalogPageForm({
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
run(() => updateCatalogPage({ id: catalogPage.id, ...form }), {
successMessage: "Page updated and catalog synced.",
errorMessage: "Failed to update.",
});
run(
() =>
updateCatalogPage({
id: catalogPage.id,
...form,
expected: catalogPage as unknown as Record<string, unknown>,
}),
{
successMessage: "Page saved.",
errorMessage: "Failed to update.",
},
);
}
async function handleDelete() {
@@ -11,3 +11,11 @@ export const CatalogManagerDialog = dynamic(
),
{ ssr: false },
);
export const CatalogManagerWorkspace = dynamic(
() =>
import("@/components/admin/catalog-manager/catalog-manager-dialog").then(
(m) => ({ default: m.CatalogManagerWorkspace }),
),
{ ssr: false },
);
+22 -7
View File
@@ -1,8 +1,10 @@
"use client";
import { FolderTree, HardHat, List, Package } from "lucide-react";
import { FolderTree, HardHat, LayoutGrid, List, Package } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { cn } from "@/lib/utils";
interface CatalogViewToggleProps {
@@ -15,11 +17,13 @@ export function CatalogViewToggle({
catalogType = "normal",
}: CatalogViewToggleProps) {
const router = useRouter();
const t = useTranslations("pages.admin.catalog.workspace");
const searchParams = useSearchParams();
function setView(view: string) {
if (view === currentView || !requestCatalogNavigation()) return;
const params = new URLSearchParams(searchParams.toString());
if (view === "tree") {
if (view === "manager") {
params.delete("view");
} else {
params.set("view", view);
@@ -28,18 +32,21 @@ export function CatalogViewToggle({
}
function setCatalog(type: string) {
if (type === catalogType || !requestCatalogNavigation()) return;
const params = new URLSearchParams(searchParams.toString());
if (type === "normal") {
params.delete("catalog");
} else {
params.set("catalog", type);
}
params.delete("view"); // reset to tree when switching
params.delete("view");
params.delete("root");
params.delete("category");
router.push(`?${params.toString()}`);
}
return (
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{/* Catalog type selector */}
<div className="flex items-center rounded-md border bg-card">
<Button
@@ -52,7 +59,7 @@ export function CatalogViewToggle({
onClick={() => setCatalog("normal")}
>
<Package className="mr-1.5 h-4 w-4" />
Catalog
{t("normal")}
</Button>
<Button
variant="ghost"
@@ -69,6 +76,14 @@ export function CatalogViewToggle({
</div>
{/* View mode selector */}
<Button
variant={currentView === "manager" ? "default" : "ghost"}
size="sm"
onClick={() => setView("manager")}
>
<LayoutGrid className="mr-1.5 h-4 w-4" />
{t("editor")}
</Button>
<div className="flex items-center rounded-md border bg-card">
<Button
variant="ghost"
@@ -80,7 +95,7 @@ export function CatalogViewToggle({
onClick={() => setView("tree")}
>
<FolderTree className="mr-1.5 h-4 w-4" />
Tree
{t("tree")}
</Button>
<Button
variant="ghost"
@@ -92,7 +107,7 @@ export function CatalogViewToggle({
onClick={() => setView("table")}
>
<List className="mr-1.5 h-4 w-4" />
Table
{t("table")}
</Button>
</div>
</div>
+57 -8
View File
@@ -15,8 +15,11 @@ import { Store } from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { CatalogFixAllButton } from "@/components/admin/catalog/catalog-fix-all-button";
import { CatalogTree } from "@/components/admin/catalog-tree";
import Link from "@/components/link";
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status";
import { loadCatalogOverview } from "@/features/catalog/server/overview";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import {
CatalogItems,
@@ -28,7 +31,10 @@ import {
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getCatalogItemCounts } from "@/lib/services/catalog-tree";
import { CatalogActions } from "./catalog-actions";
import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper";
import {
CatalogManagerDialog,
CatalogManagerWorkspace,
} from "./catalog-manager-dialog-wrapper";
import { CatalogTable } from "./catalog-table";
import { CatalogViewToggle } from "./catalog-view-toggle";
@@ -56,8 +62,44 @@ export default async function CatalogPagesPage({
const params = await searchParams;
const sp = new URLSearchParams(params);
const { search, perPage, page, sort, order } = parseListParams(sp);
const viewMode = sp.get("view") || "tree";
const catalogType = sp.get("catalog") || "normal";
const requestedView = sp.get("view");
const viewMode =
requestedView === "tree" || requestedView === "table"
? requestedView
: "manager";
const catalogType = sp.get("catalog") === "bc" ? "bc" : "normal";
if (viewMode === "manager") {
const overview = await loadCatalogOverview(catalogType);
return (
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<CatalogViewToggle currentView={viewMode} catalogType={catalogType} />
<div className="flex flex-wrap items-center gap-2">
<Link className="btn btn-outline" href="/admin/catalog/maintenance">
{t("workspace.diagnostics")}
</Link>
{canEdit && (
<CatalogActions
catalogType={catalogType}
initialParentId={sp.get("newPageParent")}
/>
)}
</div>
</div>
<div className="mb-3">
<CatalogSyncStatus canEdit={canEdit} />
<CatalogSearch catalogType={catalogType} />
</div>
<CatalogManagerWorkspace
key={catalogType}
catalogType={catalogType}
canEdit={canEdit}
initialRoots={overview.roots}
totals={overview.totals}
/>
</AdminPageShell>
);
}
type TreeNodeShape = {
id: number;
@@ -94,7 +136,7 @@ export default async function CatalogPagesPage({
.where(
or(eq(CatalogPagesBc.parentId, -1), eq(CatalogPagesBc.parentId, 0)),
)
.orderBy(asc(CatalogPagesBc.orderNum));
.orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id));
const bcRootIds = bcRootRaw.map((p) => p.id);
const [bcChildCounts, bcItemCounts] = await Promise.all([
bcRootIds.length > 0
@@ -290,7 +332,12 @@ export default async function CatalogPagesPage({
initialParentId={sp.get("newPageParent")}
/>
)}
<CatalogFixAllButton />
<Link
className="btn btn-outline"
href="/admin/catalog/maintenance"
>
{t("workspace.diagnostics")}
</Link>
</div>
</div>
{viewMode === "tree" ? (
@@ -323,7 +370,7 @@ export default async function CatalogPagesPage({
.select()
.from(CatalogPages)
.where(or(eq(CatalogPages.parentId, -1), eq(CatalogPages.parentId, 0)))
.orderBy(asc(CatalogPages.orderNum));
.orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id));
const rootIds = rootPagesRaw.map((p) => p.id);
const [rootChildCounts, rootItemCountMap] = await Promise.all([
rootIds.length > 0
@@ -511,7 +558,9 @@ export default async function CatalogPagesPage({
{canEdit && (
<CatalogActions initialParentId={sp.get("newPageParent")} />
)}
<CatalogFixAllButton />
<Link className="btn btn-outline" href="/admin/catalog/maintenance">
{t("workspace.diagnostics")}
</Link>
</div>
</div>
@@ -0,0 +1,49 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs"));
const search = vi.hoisted(() => vi.fn());
const guard = vi.hoisted(() => vi.fn());
vi.mock("@/features/catalog/server/search", () => ({ searchCatalog: search }));
vi.mock("@/features/catalog/server/errors", () => ({
catalogFailure: () => ({ message: "Invalid search", status: 400 }),
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (
options: unknown,
handler: (request: NextRequest) => Promise<Response>,
) => {
guard(options);
return handler;
},
}));
import { PERMS } from "@/lib/permissions";
import { GET } from "./route";
beforeEach(() => {
search.mockReset();
});
describe("catalog search route", () => {
it("registers the catalog read permission", () => {
expect(guard).toHaveBeenCalledWith({ permission: PERMS.CATALOG_VIEW });
});
it("forwards normal or BC without widening scope and returns results", async () => {
search.mockResolvedValue([]);
const response = await GET(
new NextRequest(
"https://cms.test/api/admin/catalog/search?catalog=bc&q=chair",
),
);
expect(search).toHaveBeenCalledWith("bc", "chair");
expect(await response.json()).toEqual({ ok: true, results: [] });
});
it("returns a validation error without a success payload", async () => {
search.mockRejectedValue(Error("Invalid search"));
const response = await GET(
new NextRequest("https://cms.test/api/admin/catalog/search?q=a"),
);
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: "Invalid search" });
});
});
+22
View File
@@ -0,0 +1,22 @@
import { catalogFailure } from "@/features/catalog/server/errors";
import { searchCatalog } from "@/features/catalog/server/search";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW },
async (request) => {
const params = request.nextUrl.searchParams;
try {
return apiOk({
results: await searchCatalog(
params.get("catalog") ?? "normal",
params.get("q") ?? "",
),
});
} catch (error) {
const failure = catalogFailure(error);
return apiError(failure.message, failure.status);
}
},
);
+18
View File
@@ -0,0 +1,18 @@
import {
readCatalogHotelStatus,
sendCatalogUpdate,
} from "@/features/catalog/server/sync-status";
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { catalogExportStatus } from "@/lib/services/catalog-git-queue";
export const GET = withAdmin({ permission: PERMS.CATALOG_VIEW }, async () => {
const [hotel, git] = await Promise.all([
readCatalogHotelStatus(),
catalogExportStatus(),
]);
return apiOk({ hotel, git });
});
export const POST = withAdmin({ permission: PERMS.CATALOG_EDIT }, async () =>
apiOk({ hotel: await sendCatalogUpdate() }),
);
+27 -87
View File
@@ -1,16 +1,20 @@
import { asc, count, eq, inArray, like, or } from "drizzle-orm";
import { catalogFailure } from "@/features/catalog/server/errors";
import {
deletePageCommand,
togglePageCommand,
} from "@/features/catalog/server/page-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { CatalogItemsBc, CatalogPages, CatalogPagesBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import {
deletePage,
getAncestors,
getCatalogItemCounts,
getTreeFlat,
toInt,
} from "@/lib/services/catalog-tree";
import { rcon } from "@/lib/services/rcon";
import type { TreeNode } from "@/types/catalog";
type PageRow = {
@@ -40,7 +44,7 @@ async function getBcTreeFlat(): Promise<TreeNode[]> {
enabled: CatalogPagesBc.enabled,
})
.from(CatalogPagesBc)
.orderBy(asc(CatalogPagesBc.orderNum)),
.orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id)),
db
.select({
pageId: CatalogItemsBc.pageId,
@@ -56,14 +60,16 @@ async function getBcTreeFlat(): Promise<TreeNode[]> {
}
const pageMap = new Map(allPages.map((p) => [p.id, p]));
const depthCache = new Map<number, number>();
function computeDepth(pageId: number): number {
function computeDepth(pageId: number, visiting = new Set<number>()): number {
if (visiting.has(pageId)) return 0;
visiting.add(pageId);
if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0;
const page = pageMap.get(pageId);
if (!page || page.parentId <= 0) {
depthCache.set(pageId, 0);
return 0;
}
const d = computeDepth(page.parentId) + 1;
const d = computeDepth(page.parentId, visiting) + 1;
depthCache.set(pageId, d);
return d;
}
@@ -134,7 +140,7 @@ async function getChildren(
})
.from(CatalogPagesBc)
.where(parentFilterBc)
.orderBy(asc(CatalogPagesBc.orderNum));
.orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id));
const ids = pages.map((p) => toInt(p.id));
const [childCounts, itemCounts] = await Promise.all([
ids.length
@@ -199,7 +205,7 @@ async function getChildren(
})
.from(CatalogPages)
.where(parentFilter)
.orderBy(asc(CatalogPages.orderNum));
.orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id));
const ids = pages.map((p) => toInt(p.id));
const [childCounts, itemMap] = await Promise.all([
ids.length
@@ -259,7 +265,7 @@ async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
})
.from(CatalogPagesBc)
.where(or(...conditions))
.orderBy(asc(CatalogPagesBc.orderNum))
.orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id))
.limit(50);
return pages.map((p) =>
toTreeNode(
@@ -296,7 +302,7 @@ async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
})
.from(CatalogPages)
.where(or(...conditions))
.orderBy(asc(CatalogPages.orderNum))
.orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id))
.limit(50);
const ids = pages.map((p) => toInt(p.id));
const [childCounts, itemMap] = await Promise.all([
@@ -430,39 +436,13 @@ export const PATCH = withAdmin(
return apiError("Invalid pageId");
}
const isBc = body.catalog === "bc";
if (isBc) {
const [page] = await db
.select({
enabled: CatalogPagesBc.enabled,
visible: CatalogPagesBc.visible,
})
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, pageId))
.limit(1);
if (!page) return apiError("Page not found", 404);
const current = field === "enabled" ? page.enabled : page.visible;
await db
.update(CatalogPagesBc)
.set({ [field]: current === "1" ? "0" : "1" })
.where(eq(CatalogPagesBc.id, pageId));
await rcon.updateCatalog();
return apiOk({});
try {
await togglePageCommand(isBc ? "bc" : "normal", pageId, field);
} catch (error) {
const failure = catalogFailure(error);
return apiError(failure.message, failure.status);
}
const [page] = await db
.select({
enabled: CatalogPages.enabled,
visible: CatalogPages.visible,
})
.from(CatalogPages)
.where(eq(CatalogPages.id, pageId))
.limit(1);
if (!page) return apiError("Page not found", 404);
const current = field === "enabled" ? page.enabled : page.visible;
await db
.update(CatalogPages)
.set({ [field]: current === "1" ? "0" : "1" })
.where(eq(CatalogPages.id, pageId));
await rcon.updateCatalog();
await sendCatalogUpdate();
return apiOk({});
},
);
@@ -477,53 +457,13 @@ export const DELETE = withAdmin(
if (!Number.isFinite(pageId) || pageId <= 0) {
return apiError("Invalid pageId");
}
if (isBc) {
const [page] = await db
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, pageId))
.limit(1);
if (!page) return apiError("Page not found", 404);
if (mode === "reparent") {
await db.transaction(async (tx) => {
await tx
.update(CatalogPagesBc)
.set({ parentId: page.parentId })
.where(eq(CatalogPagesBc.parentId, pageId));
await tx
.delete(CatalogItemsBc)
.where(eq(CatalogItemsBc.pageId, pageId));
await tx.delete(CatalogPagesBc).where(eq(CatalogPagesBc.id, pageId));
});
} else {
// Deep cascade: collect all descendants
const toDelete: number[] = [pageId];
const queue: number[] = [pageId];
while (queue.length > 0) {
const children = await db
.select({ id: CatalogPagesBc.id })
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, queue));
queue.length = 0;
for (const child of children) {
toDelete.push(child.id);
queue.push(child.id);
}
}
await db.transaction(async (tx) => {
await tx
.delete(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, toDelete));
await tx
.delete(CatalogPagesBc)
.where(inArray(CatalogPagesBc.id, toDelete));
});
}
await rcon.updateCatalog();
return apiOk({});
try {
await deletePageCommand(isBc ? "bc" : "normal", pageId, mode);
} catch (error) {
const failure = catalogFailure(error);
return apiError(failure.message, failure.status);
}
await deletePage(pageId, mode);
await rcon.updateCatalog();
await sendCatalogUpdate();
return apiOk({});
},
);
@@ -4,7 +4,11 @@ import { ChevronRight, Home } from "lucide-react";
import { Fragment, useEffect } from "react";
import { useCatalogManager } from "./use-catalog-store";
export function BreadcrumbBar() {
export function BreadcrumbBar({
onSelectPage,
}: {
onSelectPage: (id: number) => void;
}) {
const { state, dispatch, catalogType } = useCatalogManager();
const { selectedPageId, ancestors } = state;
const catQs = catalogType === "bc" ? "&catalog=bc" : "";
@@ -40,7 +44,7 @@ export function BreadcrumbBar() {
<ChevronRight className="h-3 w-3 shrink-0" />
<button
type="button"
onClick={() => dispatch({ type: "SELECT_PAGE", id: a.id })}
onClick={() => onSelectPage(a.id)}
className="shrink-0 hover:text-foreground transition-colors truncate max-w-40"
>
{a.caption}
@@ -1,6 +1,7 @@
"use client";
import { LayoutGrid, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
@@ -8,6 +9,9 @@ import { createCatalogPage } from "@/actions/catalog";
import { createBcPage } from "@/actions/catalog-bc";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url";
import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
import type { RootTab } from "@/types/catalog";
import { BreadcrumbBar } from "./breadcrumb-bar";
import { InlineEditor, type PageUpdateData } from "./inline-editor";
@@ -21,12 +25,49 @@ import {
useCatalogManager,
} from "./use-catalog-store";
const EMPTY_ROOTS: RootTab[] = [];
interface CatalogManagerProps {
initialRoots?: RootTab[];
catalogType?: CatalogManagerMode;
canEdit?: boolean;
totals?: CatalogStatsTotals | null;
}
export function CatalogManagerWorkspace({
initialRoots = EMPTY_ROOTS,
catalogType = "normal",
canEdit = false,
totals = null,
}: CatalogManagerProps) {
const t = useTranslations("pages.admin.catalog.workspace");
return (
<section
aria-label={t("manager")}
className="flex h-[calc(100dvh-12rem)] min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]"
>
<CatalogManagerProvider
key={catalogType}
catalogType={catalogType}
canEdit={canEdit}
>
<CatalogManagerInner
open
embedded
initialRoots={initialRoots}
totals={totals}
/>
</CatalogManagerProvider>
</section>
);
}
/**
* Visual Catalog Manager — matches habbo-next UX (full-viewport editor).
* Portal overlay (not Base UI Dialog) so the tree + editor mount reliably.
*/
export function CatalogManagerDialog({
initialRoots = [],
initialRoots = EMPTY_ROOTS,
catalogType = "normal",
canEdit = false,
totals = null,
@@ -44,17 +85,19 @@ export function CatalogManagerDialog({
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
const previousFocus = document.activeElement as HTMLElement | null;
const focusFrame = requestAnimationFrame(() =>
document
.querySelector<HTMLElement>(
'[aria-labelledby="visual-catalog-manager-title"] button[aria-label="Close"]',
)
?.focus(),
);
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => {
cancelAnimationFrame(focusFrame);
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
previousFocus?.focus();
};
}, [open]);
@@ -103,24 +146,81 @@ export function CatalogManagerDialog({
}
function CatalogManagerInner({
embedded = false,
open,
onOpenChange,
initialRoots,
totals,
}: {
embedded?: boolean;
open: boolean;
onOpenChange: (v: boolean) => void;
onOpenChange?: (v: boolean) => void;
initialRoots: RootTab[];
totals?: CatalogStatsTotals | null;
}) {
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const rootRequests = useLatestRequest();
const tActions = useTranslations("pages.admin.actions");
const t = useTranslations("pages.admin.catalog.workspace");
const mayLeave = useCallback(
() => !state.editorDirty || window.confirm(tActions("leaveUnsaved")),
[state.editorDirty, tActions],
);
const restoreSelection = useCallback(
(selection: CatalogSelection) => {
dispatch({ type: "RESTORE_SELECTION", ...selection });
},
[dispatch],
);
const saveSelectionUrl = useCatalogNavigation({
enabled: embedded,
restore: restoreSelection,
mayLeave,
});
const close = useCallback(() => {
if (mayLeave()) onOpenChange?.(false);
}, [mayLeave, onOpenChange]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (embedded || event.key !== "Escape" || event.defaultPrevented) return;
const target = event.target as HTMLElement;
const focusedDialog = target.closest?.("[role=dialog]");
if (
focusedDialog &&
focusedDialog.getAttribute("aria-labelledby") !==
"visual-catalog-manager-title"
)
return;
if (target.closest?.("[role=listbox], [role=menu], [role=combobox]"))
return;
event.preventDefault();
close();
};
const onBeforeUnload = (event: BeforeUnloadEvent) => {
if (state.editorDirty) {
event.preventDefault();
event.returnValue = "";
}
};
window.addEventListener("keydown", onKey);
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, [close, state.editorDirty, embedded]);
const loadRootPages = useCallback(async () => {
const request = rootRequests.start();
dispatch({ type: "SET_LOADING_ROOT", loading: true });
try {
const res = await fetch(`/api/admin/catalog/tree?parentId=-1${catQs}`);
const res = await fetch(`/api/admin/catalog/tree?parentId=-1${catQs}`, {
signal: request.signal,
});
const data = await res.json().catch(() => ({}));
if (!request.isCurrent()) return;
if (!res.ok) throw new Error(data.error || "Failed to load roots");
const pages: RootTab[] = (data.pages || []).map(
(p: Record<string, unknown>) => ({
@@ -132,51 +232,32 @@ function CatalogManagerInner({
}),
);
dispatch({ type: "SET_ROOT_PAGES", pages });
const activeStillValid =
state.activeTabId !== null &&
pages.some((p) => p.id === state.activeTabId);
if (pages.length > 0 && !activeStillValid) {
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
}
} catch {
if (!request.isCurrent()) return;
toast.error("Failed to load catalog root pages");
dispatch({ type: "SET_LOADING_ROOT", loading: false });
}
}, [dispatch, state.activeTabId, catQs]);
}, [dispatch, catQs, rootRequests]);
// Seed from SSR immediately, then refresh from API.
// Seed once on opening, then refresh independently of category selection.
useEffect(() => {
if (!open) return;
if (initialRoots.length > 0 && state.rootPages.length === 0) {
const pages = initialRoots.map((p) => ({
...p,
id: Number(p.id),
enabled: String(p.enabled),
}));
dispatch({ type: "SET_ROOT_PAGES", pages });
if (state.activeTabId === null && pages[0]) {
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
}
}
if (initialRoots.length)
dispatch({ type: "SET_ROOT_PAGES", pages: initialRoots });
void loadRootPages();
}, [
open,
initialRoots,
state.rootPages.length,
state.activeTabId,
dispatch,
loadRootPages,
]);
return () => rootRequests.cancel();
}, [open, initialRoots, dispatch, loadRootPages, rootRequests]);
useEffect(() => {
if (!open) dispatch({ type: "ON_DIALOG_CLOSE" });
}, [open, dispatch]);
const handleTabClick = (id: number) => {
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
if (id === state.activeTabId) return;
if (!mayLeave()) {
return;
}
saveSelectionUrl({ rootId: id, pageId: null });
dispatch({ type: "SET_ACTIVE_TAB", id });
};
@@ -204,25 +285,24 @@ function CatalogManagerInner({
};
const handleSelectPage = (id: number) => {
if (id === state.selectedPageId) return;
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
return;
}
saveSelectionUrl({ rootId: state.activeTabId, pageId: id > 0 ? id : null });
dispatch({ type: "SELECT_PAGE", id: id > 0 ? id : null });
};
const handleEditTab = (id: number) => {
if (state.activeTabId !== id) {
dispatch({ type: "SET_ACTIVE_TAB", id });
}
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
return;
}
if (id === state.selectedPageId && id === state.activeTabId) return;
if (!mayLeave()) return;
saveSelectionUrl({ rootId: id, pageId: id });
if (state.activeTabId !== id) dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "SELECT_PAGE", id });
};
const handleEditorSaved = (updated: PageUpdateData) => {
dispatch({ type: "UPDATE_NODE", update: { ...updated } });
dispatch({ type: "SET_DIRTY", dirty: false });
const rootIdx = state.rootPages.findIndex((p) => p.id === updated.id);
if (rootIdx !== -1) {
@@ -254,19 +334,21 @@ function CatalogManagerInner({
<LayoutGrid className="h-4 w-4" />
{heading}
</h2>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => onOpenChange(false)}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
{!embedded && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={close}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<BreadcrumbBar />
<BreadcrumbBar onSelectPage={handleSelectPage} />
<RootTabs
tabs={state.rootPages}
@@ -274,6 +356,7 @@ function CatalogManagerInner({
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={handleCreateRoot}
onMutation={loadRootPages}
loading={state.loadingRoot}
/>
@@ -281,17 +364,28 @@ function CatalogManagerInner({
className="flex min-h-0 flex-1 overflow-hidden"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
<div
className="flex min-h-0 min-w-0 flex-1 flex-col"
className={`${state.selectedPageId ? "hidden md:flex" : "flex"} min-h-0 min-w-0 flex-1 md:flex-none [&>div]:w-full md:[&>div]:w-80`}
>
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
</div>
<div
className={`${state.selectedPageId ? "flex" : "hidden md:flex"} min-h-0 min-w-0 flex-1 flex-col`}
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<Button
variant="ghost"
className="shrink-0 self-start md:hidden"
onClick={() => handleSelectPage(0)}
>
{t("backToCategories")}
</Button>
<InlineEditor
pageId={state.selectedPageId}
onSaved={handleEditorSaved}
@@ -11,6 +11,7 @@ import {
Save,
Settings,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { updateCatalogPage } from "@/actions/catalog";
@@ -41,6 +42,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
import {
CATALOG_LAYOUTS,
type CatalogLayout,
@@ -106,7 +108,21 @@ interface InlineEditorProps {
type FormState = Record<string, string | number | null>;
export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
export function InlineEditor(props: InlineEditorProps) {
const { catalogType } = useCatalogManager();
return (
<InlineEditorSession key={`${catalogType}:${props.pageId}`} {...props} />
);
}
function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
const pageRequests = useLatestRequest();
const itemRequests = useLatestRequest();
const saveRequests = useLatestRequest();
const tActions = useTranslations("pages.admin.actions");
const tNavigation = useTranslations("pages.admin.navigation");
const t = useTranslations("pages.admin.catalog.workspace");
const [offersDirty, setOffersDirty] = useState(false);
const { dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const pageHref =
@@ -126,6 +142,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
const [itemsData, setItemsData] = useState<ItemsData | null>(null);
const [itemsLoading, setItemsLoading] = useState(false);
const [itemsLoaded, setItemsLoaded] = useState(false);
const [itemsError, setItemsError] = useState(false);
// ── Dirty tracking ──────────────────────────────────────────
const isDirty = useMemo(() => {
@@ -133,8 +150,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
}, [form, original]);
useEffect(() => {
dispatch({ type: "SET_DIRTY", dirty: isDirty });
}, [isDirty, dispatch]);
dispatch({ type: "SET_DIRTY", dirty: isDirty || offersDirty });
}, [isDirty, offersDirty, dispatch]);
// ── Ctrl+S handler ──────────────────────────────────────────
useEffect(() => {
@@ -151,11 +168,16 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
const loadPage = useCallback(
async (id: number) => {
const request = pageRequests.start();
setLoading(true);
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}${catQs}`);
const res = await fetch(
`/api/admin/catalog/tree?pageId=${id}${catQs}`,
{ signal: request.signal },
);
if (!res.ok) throw new Error();
const data = await res.json();
if (!request.isCurrent()) return;
const p = data.page as PageDetail;
setPage(p);
const formData: FormState = {
@@ -163,8 +185,9 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
pageLayout: p.pageLayout,
enabled: String(p.enabled),
visible: String(p.visible),
minRank: p.minRank ?? 0,
clubOnly: String(p.clubOnly ?? "0"),
...(catalogType === "normal"
? { minRank: p.minRank ?? 0, clubOnly: String(p.clubOnly ?? "0") }
: {}),
orderNum: p.orderNum,
iconImage: p.iconImage,
iconColor: p.iconColor,
@@ -179,29 +202,41 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
setForm(formData);
setOriginal(formData);
} catch {
if (!request.isCurrent()) return;
toast.error("Failed to load page");
setPage(null);
} finally {
setLoading(false);
if (request.isCurrent()) setLoading(false);
}
},
[catQs],
[catQs, catalogType, pageRequests],
);
const loadItemsData = useCallback(async (id: number) => {
setItemsLoading(true);
try {
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`);
if (!res.ok) throw new Error();
const data = await res.json();
setItemsData(data as ItemsData);
setItemsLoaded(true);
} catch {
toast.error("Failed to load items");
} finally {
setItemsLoading(false);
}
}, []);
const loadItemsData = useCallback(
async (id: number) => {
const request = itemRequests.start();
setItemsError(false);
setItemsLoading(true);
try {
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`, {
signal: request.signal,
});
if (!res.ok) throw new Error();
const data = await res.json();
if (!request.isCurrent()) return;
setItemsData(data as ItemsData);
setItemsLoaded(true);
} catch {
if (!request.isCurrent()) return;
setItemsLoaded(true);
setItemsError(true);
toast.error("Failed to load items");
} finally {
if (request.isCurrent()) setItemsLoading(false);
}
},
[itemRequests],
);
const reloadItems = useCallback(() => {
if (pageId && pageId > 0) loadItemsData(pageId);
@@ -250,15 +285,21 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
}
async function handleSave() {
if (!page || !canEdit) return;
if (!page || !canEdit || saving) return;
const request = saveRequests.start();
setSaving(true);
try {
const result =
catalogType === "bc"
? await updateBcPage({ id: page.id, ...form })
: await updateCatalogPage({ id: page.id, ...form });
? await updateBcPage({ id: page.id, ...form, expected: original })
: await updateCatalogPage({
id: page.id,
...form,
expected: original,
});
if (!request.isCurrent()) return;
if (!result.ok) throw new Error(result.error || "Failed to save");
toast.success("Page updated & catalog synced");
toast.success(tActions("saved"));
setOriginal({ ...form });
setPage((prev) =>
prev
@@ -281,9 +322,10 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
iconImage: (form.iconImage as number) ?? page.iconImage,
});
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save");
if (request.isCurrent())
toast.error(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
if (request.isCurrent()) setSaving(false);
}
}
@@ -303,7 +345,14 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
);
}
if (!page) return null;
if (!page)
return (
<div className="flex flex-1 items-center justify-center">
<Button variant="outline" onClick={() => loadPage(pageId)}>
{t("retryPage")}
</Button>
</div>
);
const layoutColor =
LAYOUT_COLORS[String(form.pageLayout)] || "bg-muted text-muted-foreground";
@@ -386,7 +435,12 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
onValueChange={(tab) => {
if (tab === activeTab) return;
if (offersDirty && !window.confirm(tActions("leaveUnsaved"))) return;
setOffersDirty(false);
setActiveTab(tab);
}}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="mx-4 mt-2 shrink-0">
@@ -490,17 +544,19 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Min Rank</Label>
<Input
type="number"
value={form.minRank as number}
onChange={(e) =>
update("minRank", parseInt(e.target.value, 10) || 1)
}
className="h-8 text-sm"
/>
</div>
{catalogType === "normal" && (
<div className="space-y-1.5">
<Label className="text-xs">Min Rank</Label>
<Input
type="number"
value={form.minRank as number}
onChange={(e) =>
update("minRank", parseInt(e.target.value, 10) || 1)
}
className="h-8 text-sm"
/>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs">Icon</Label>
<IconPicker
@@ -526,13 +582,15 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
/>
<ToggleRow
id="ie-club"
label="Club Only"
description="Requires HC subscription"
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
/>
{catalogType === "normal" && (
<ToggleRow
id="ie-club"
label="Club Only"
description="Requires HC subscription"
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
/>
)}
</div>
{/* Headline image picker */}
@@ -641,7 +699,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save & Sync
{tNavigation("save")}
</Button>
</div>
</div>
@@ -662,7 +720,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
</Button>
</Link>
</div>
) : itemsLoading ? (
) : itemsError && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" onClick={reloadItems}>
{t("retryItems")}
</Button>
</div>
) : itemsLoading && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@@ -704,6 +768,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
{/* Items table */}
<CatalogItemsTable
onDirtyChange={setOffersDirty}
onRefresh={reloadItems}
items={itemsData.items}
pageId={page.id}
pageLayout={page.pageLayout}
@@ -725,7 +791,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
Translate tools are available for the shop catalog only.
</div>
) : itemsLoading ? (
) : itemsError && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" onClick={reloadItems}>
{t("retryItems")}
</Button>
</div>
) : itemsLoading && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@@ -22,12 +22,12 @@ import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
deleteTreePage,
reorderTreePage,
reorderCatalogPages,
toggleCatalogPage,
} from "@/actions/catalog";
import {
deleteBcTreePage,
reorderBcTreePage,
reorderBcCatalogPages,
toggleBcPage,
} from "@/actions/catalog-bc";
import { CatalogIcon } from "@/components/admin/catalog-tree";
@@ -40,6 +40,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { cn } from "@/lib/utils";
import { useCatalogManager } from "./use-catalog-store";
@@ -54,6 +55,7 @@ interface RootTabsProps {
onEditTab: (id: number) => void | Promise<void>;
onCreateRoot: () => void;
loading?: boolean;
onMutation?: () => void;
}
export function RootTabs({
@@ -63,6 +65,7 @@ export function RootTabs({
onEditTab,
onCreateRoot,
loading,
onMutation,
}: RootTabsProps) {
const { catalogType, canEdit } = useCatalogManager();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
@@ -85,26 +88,26 @@ export function RootTabs({
const newIndex = tabs.findIndex((t) => t.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
if (!canEdit) return;
if (!canEdit || !requestCatalogNavigation()) return;
const newOrder = arrayMove(tabs, oldIndex, newIndex);
// Batch reorder
try {
const reorder =
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
const results = await Promise.allSettled(
newOrder.map((tab, idx) =>
reorder({ pageId: tab.id, newOrderNum: idx + 1 }),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed) toast.error(`${failed} reorder(s) failed`);
else toast.success("Tabs reordered");
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
const result = await reorder({
parentId: -1,
ids: newOrder.map((tab) => tab.id),
expectedIds: tabs.map((tab) => tab.id),
});
if (!result.ok) throw Error("Failed to reorder tabs");
toast.success("Tabs reordered");
onMutation?.();
} catch {
toast.error("Failed to reorder tabs");
}
},
[tabs, canEdit, catalogType],
[tabs, canEdit, catalogType, onMutation],
);
const handleToggle = async (tab: RootTab) => {
@@ -118,6 +121,7 @@ export function RootTabs({
toast.success(
`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`,
);
onMutation?.();
} catch {
toast.error("Failed to toggle");
}
@@ -138,6 +142,7 @@ export function RootTabs({
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
}
toast.success(`"${tab.caption}" deleted`);
onMutation?.();
} catch {
toast.error("Failed to delete");
}
@@ -36,6 +36,7 @@ import { toast } from "sonner";
import {
createCatalogPage,
deleteTreePage,
reorderCatalogPages,
reorderTreePage,
toggleCatalogPage,
updateCatalogPage,
@@ -43,6 +44,7 @@ import {
import {
createBcPage,
deleteBcTreePage,
reorderBcCatalogPages,
reorderBcTreePage,
toggleBcPage,
updateBcPage,
@@ -113,7 +115,6 @@ export function SortableTree({
const [subpageName, setSubpageName] = useState("");
const [creatingSubpage, setCreatingSubpage] = useState(false);
const [searching, setSearching] = useState(false);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [activeId, setActiveId] = useState<number | null>(null);
const [treeLoading, setTreeLoading] = useState(false);
const [filter, setFilter] = useState<FilterMode>("all");
@@ -189,29 +190,32 @@ export function SortableTree({
void loadTabTree();
}, [activeTabId, loadTabTree, dispatch]);
// Debounced search
// Ignore stale search completions when the query or catalog changes.
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults(null);
return;
}
if (searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(async () => {
const controller = new AbortController();
setSearchResults(null);
setSearching(false);
if (!searchQuery.trim()) return;
const timeout = setTimeout(async () => {
setSearching(true);
try {
const res = await fetch(
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catQs}`,
{ signal: controller.signal },
);
if (res.ok) {
const data = await res.json();
setSearchResults(data.pages || []);
}
if (!res.ok) throw new Error("Search failed");
const data = await res.json();
if (!controller.signal.aborted) setSearchResults(data.pages || []);
} catch {
if (!controller.signal.aborted)
toast.error("Failed to search catalog pages");
} finally {
setSearching(false);
if (!controller.signal.aborted) setSearching(false);
}
}, 300);
return () => {
if (searchTimeout.current) clearTimeout(searchTimeout.current);
controller.abort();
clearTimeout(timeout);
};
}, [searchQuery, catQs]);
@@ -423,7 +427,8 @@ export function SortableTree({
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id || activeTabId === null) return;
if (!canEdit || !over || active.id === over.id || activeTabId === null)
return;
const activeNode = nodes.get(active.id as number);
const overNode = nodes.get(over.id as number);
@@ -446,16 +451,15 @@ export function SortableTree({
if (!canEdit) return;
const reorder =
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
try {
const results = await Promise.allSettled(
newOrder.map((id, idx) =>
reorder({ pageId: id, newOrderNum: idx + 1 }),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) toast.error(`${failed} reorder(s) failed`);
else toast.success("Reordered");
const result = await reorder({
parentId,
ids: newOrder,
expectedIds: siblings,
});
if (!result.ok) throw Error("Failed to reorder");
toast.success("Reordered");
onTreeMutation();
} catch {
toast.error("Failed to reorder");
@@ -317,6 +317,7 @@ export const TreeItemContent = React.memo(
},
(prev, next) => {
return (
prev.onSelect === next.onSelect &&
prev.node.id === next.node.id &&
prev.node.caption === next.node.caption &&
prev.node.enabled === next.node.enabled &&
@@ -59,6 +59,7 @@ const initialState: CatalogManagerState = {
// ── Actions ───────────────────────────────────────────────────
export type CatalogAction =
| { type: "RESTORE_SELECTION"; rootId: number | null; pageId: number | null }
| { type: "SET_ROOT_PAGES"; pages: RootTab[] }
| { type: "SET_LOADING_ROOT"; loading: boolean }
| { type: "SET_ACTIVE_TAB"; id: number | null }
@@ -93,14 +94,33 @@ function reducer(
action: CatalogAction,
): CatalogManagerState {
switch (action.type) {
case "RESTORE_SELECTION":
return {
...state,
activeTabId:
action.rootId ?? state.rootPages[0]?.id ?? state.activeTabId,
selectedPageId: action.pageId,
focusedId: action.pageId,
editorDirty: false,
multiSelectedIds: new Set(),
ancestors: [],
};
case "SET_ROOT_PAGES":
return { ...state, rootPages: action.pages, loadingRoot: false };
return {
...state,
rootPages: action.pages,
loadingRoot: false,
activeTabId: action.pages.some((page) => page.id === state.activeTabId)
? state.activeTabId
: (action.pages[0]?.id ?? null),
};
case "SET_LOADING_ROOT":
return { ...state, loadingRoot: action.loading };
case "SET_ACTIVE_TAB":
return {
...state,
activeTabId: action.id,
editorDirty: false,
selectedPageId: null,
multiSelectedIds: new Set(),
focusedId: null,
@@ -110,6 +130,7 @@ function reducer(
return {
...state,
selectedPageId: action.id,
editorDirty: false,
multiSelectedIds: new Set(),
focusedId: action.id,
};
@@ -1,55 +0,0 @@
"use client";
import { AlertTriangle } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { fixEverythingAction } from "@/actions/furni-maintenance";
import { Button } from "@/components/ui/button";
/**
* One-click "fix everything" for the furniture tables. Runs sprite fix, catalog
* offer fix, FurnitureData reconciliation, dedup and ID alignment in order, all
* inside a single DB transaction on the server. Heavy — guarded by a confirm.
*/
export function CatalogFixAllButton() {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function run() {
if (
!window.confirm(
"Alle meubel-tabellen repareren (sprite-id, offer_id, dubbelen, IDs naar FurnitureData)?\nDit is een zware, niet-omkeerbare actie. Zorg dat er een backup is.",
)
) {
return;
}
setBusy(true);
try {
const res = await fixEverythingAction();
if (res.ok) {
toast.success("Alle reparaties uitgevoerd");
router.refresh();
} else {
toast.error(res.error ?? "Mislukt");
}
} catch (e) {
toast.error(String(e));
} finally {
setBusy(false);
}
}
return (
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={() => void run()}
title="Alle meubel-tabellen repareren"
>
<AlertTriangle className="mr-2 h-4 w-4" />
{busy ? "Bezig…" : "Fix alles"}
</Button>
);
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
readCatalogSelection,
writeCatalogSelection,
} from "./catalog-selection-url";
describe("catalog selection URL", () => {
it("restores category context without disturbing catalog, search or pagination", () => {
const url = new URL(
"https://cms.test/admin/catalog?catalog=bc&search=chair&page=4",
);
const selected = writeCatalogSelection(url, { rootId: 8, pageId: 12 });
expect(readCatalogSelection(selected)).toEqual({ rootId: 8, pageId: 12 });
expect(selected.searchParams.get("catalog")).toBe("bc");
expect(selected.searchParams.get("search")).toBe("chair");
expect(selected.searchParams.get("page")).toBe("4");
expect(url.searchParams.has("category")).toBe(false);
expect(
writeCatalogSelection(selected, {
rootId: 8,
pageId: null,
}).searchParams.has("category"),
).toBe(false);
});
it.each(["-1", "0", "1.5", "1abc", "Infinity", "9007199254740992"])(
"rejects invalid identifiers: %s",
(id) => {
expect(
readCatalogSelection(
new URL(`https://cms.test/?root=${id}&category=${id}`),
),
).toEqual({ rootId: null, pageId: null });
},
);
});
@@ -0,0 +1,29 @@
export interface CatalogSelection {
rootId: number | null;
pageId: number | null;
}
function positiveId(value: string | null) {
if (!value || !/^\d+$/.test(value)) return null;
const id = Number(value);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
export function readCatalogSelection(url: URL): CatalogSelection {
return {
rootId: positiveId(url.searchParams.get("root")),
pageId: positiveId(url.searchParams.get("category")),
};
}
export function writeCatalogSelection(
url: URL,
selection: CatalogSelection,
): URL {
const next = new URL(url);
for (const [key, value] of [
["root", selection.rootId],
["category", selection.pageId],
] as const) {
if (value === null) next.searchParams.delete(key);
else next.searchParams.set(key, String(value));
}
return next;
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { createLatestRequest } from "./latest-request";
describe("catalog request ownership", () => {
it("keeps the latest category when an earlier transport completes last", async () => {
const requests = createLatestRequest();
let displayed = "";
let finishOld!: (value: string) => void;
const oldResponse = new Promise<string>((resolve) => {
finishOld = resolve;
});
const oldRequest = requests.start();
const oldWork = oldResponse.then((value) => {
if (oldRequest.isCurrent()) displayed = value;
});
const current = requests.start();
await Promise.resolve("category B").then((value) => {
if (current.isCurrent()) displayed = value;
});
finishOld("category A");
await oldWork;
expect(oldRequest.signal.aborted).toBe(true);
expect(displayed).toBe("category B");
});
it("ignores late save/read completion after editor unmount", () => {
const requests = createLatestRequest();
const pending = requests.start();
requests.cancel();
expect(pending.signal.aborted).toBe(true);
expect(pending.isCurrent()).toBe(false);
});
it("allows a fresh request after effect cleanup and isolates page/items channels", () => {
const pages = createLatestRequest();
const items = createLatestRequest();
const page = pages.start();
const firstItems = items.start();
items.cancel();
const latestItems = items.start();
expect(page.isCurrent()).toBe(true);
expect(firstItems.isCurrent()).toBe(false);
expect(latestItems.isCurrent()).toBe(true);
});
});
@@ -0,0 +1,19 @@
/** Cancels replaced requests and rejects late completions, even if transport ignores abort. */
export function createLatestRequest() {
let current: AbortController | null = null;
return {
start() {
current?.abort();
const controller = new AbortController();
current = controller;
return {
signal: controller.signal,
isCurrent: () => current === controller && !controller.signal.aborted,
};
},
cancel() {
current?.abort();
current = null;
},
};
}
@@ -0,0 +1,131 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import {
type CatalogSelection,
readCatalogSelection,
writeCatalogSelection,
} from "./catalog-selection-url";
export function requestCatalogNavigation() {
return window.dispatchEvent(
new Event("catalog:before-navigation", { cancelable: true }),
);
}
/** Selection history stays local: changing category does not reload the server page. */
export function useCatalogNavigation({
enabled,
restore,
mayLeave,
}: {
enabled: boolean;
restore: (selection: CatalogSelection) => void;
mayLeave: () => boolean;
}) {
const current = useRef({ restore, mayLeave });
useEffect(() => {
current.current = { restore, mayLeave };
}, [restore, mayLeave]);
const previousUrl = useRef("");
const position = useRef(0);
const restoringHistory = useRef(false);
useEffect(() => {
if (!enabled) return;
previousUrl.current = window.location.href;
position.current = window.history.state?.catalogSelectionPosition ?? 0;
window.history.replaceState(
{ ...window.history.state, catalogSelectionPosition: position.current },
"",
);
current.current.restore(
readCatalogSelection(new URL(window.location.href)),
);
const onPopState = () => {
if (restoringHistory.current) {
restoringHistory.current = false;
return;
}
const targetPosition = window.history.state?.catalogSelectionPosition;
if (!current.current.mayLeave()) {
if (
typeof targetPosition === "number" &&
targetPosition !== position.current
) {
restoringHistory.current = true;
window.history.go(position.current - targetPosition);
return;
}
window.history.replaceState(
window.history.state,
"",
previousUrl.current,
);
return;
}
position.current =
typeof targetPosition === "number" ? targetPosition : position.current;
previousUrl.current = window.location.href;
current.current.restore(
readCatalogSelection(new URL(window.location.href)),
);
};
const onLink = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
)
return;
const link = (event.target as Element).closest?.(
"a[href]",
) as HTMLAnchorElement | null;
if (
!link ||
link.target === "_blank" ||
link.hasAttribute("download") ||
link.href === window.location.href
)
return;
if (!current.current.mayLeave()) {
event.preventDefault();
event.stopPropagation();
}
};
const onNavigation = (event: Event) => {
if (!current.current.mayLeave()) event.preventDefault();
};
window.addEventListener("catalog:before-navigation", onNavigation);
window.addEventListener("popstate", onPopState);
document.addEventListener("click", onLink, true);
return () => {
window.removeEventListener("catalog:before-navigation", onNavigation);
window.removeEventListener("popstate", onPopState);
document.removeEventListener("click", onLink, true);
};
}, [enabled]);
return useCallback(
(selection: CatalogSelection) => {
if (!enabled) return;
const url = writeCatalogSelection(
new URL(window.location.href),
selection,
);
if (url.href !== window.location.href) {
position.current += 1;
window.history.pushState(
{
...window.history.state,
catalogSelectionPosition: position.current,
},
"",
url,
);
}
previousUrl.current = url.href;
},
[enabled],
);
}
@@ -0,0 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { createLatestRequest } from "./latest-request";
export function useLatestRequest() {
const [requests] = useState(createLatestRequest);
useEffect(() => () => requests.cancel(), [requests]);
return requests;
}
@@ -0,0 +1,128 @@
"use client";
import { Loader2, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import Link from "@/components/link";
import { Input } from "@/components/ui/input";
import { useLatestRequest } from "../client/use-latest-request";
import {
type CatalogSearchResult,
type SearchCatalogKind,
validSearchQuery,
} from "../domain/search";
export function CatalogSearch({
catalogType = "normal",
}: {
catalogType?: SearchCatalogKind;
}) {
const t = useTranslations("pages.admin.catalog.workspace");
const [query, setQuery] = useState("");
const [results, setResults] = useState<CatalogSearchResult[]>([]);
const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">(
"idle",
);
const requests = useLatestRequest();
useEffect(() => {
const request = requests.start();
setResults([]);
if (!validSearchQuery(query)) {
setStatus("idle");
return () => requests.cancel();
}
setStatus("loading");
const timer = setTimeout(async () => {
try {
const params = new URLSearchParams({
q: query.trim(),
catalog: catalogType,
});
const response = await fetch(`/api/admin/catalog/search?${params}`, {
signal: request.signal,
});
if (!response.ok) throw Error("Search failed");
const data = await response.json();
if (!request.isCurrent()) return;
setResults(data.results);
setStatus("ready");
} catch {
if (request.isCurrent()) setStatus("error");
}
}, 250);
return () => {
clearTimeout(timer);
requests.cancel();
};
}, [query, catalogType, requests]);
return (
<section
aria-label={t("globalSearch")}
className="min-w-0 space-y-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 text-[var(--admin-text)]"
>
<div className="flex min-w-0 items-center gap-2">
<Search
aria-hidden
className="h-4 w-4 shrink-0 text-muted-foreground"
/>
<Input
aria-label={t("globalSearch")}
placeholder={t("searchPlaceholder")}
value={query}
onChange={(event) => setQuery(event.target.value)}
maxLength={100}
className="min-w-0"
/>
</div>
<div aria-live="polite">
{status === "loading" && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
{t("searching")}
</p>
)}
{status === "error" && (
<p role="alert" className="text-sm text-[var(--admin-error)]">
{t("searchFailed")}
</p>
)}
{status === "ready" && results.length === 0 && (
<p className="text-sm text-muted-foreground">
{t("searchNoResults")}
</p>
)}
</div>
{results.length > 0 && (
<ul className="max-h-80 overflow-y-auto divide-y divide-[var(--admin-border)]">
{results.map((result) => (
<li key={result.kind + result.id}>
<Link
href={result.href}
className="block min-w-0 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2"
>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-xs text-muted-foreground">
{t(result.kind)}
</span>
<span className="font-medium break-words">
{result.caption}
</span>
<span className="text-xs text-muted-foreground">
#{result.id}
</span>
</div>
<p className="break-words text-xs text-muted-foreground">
{result.path.join(" / ")}
</p>
{result.furniture && (
<p className="break-words text-xs text-muted-foreground">
{result.furniture.classname} · #{result.furniture.id} ·
sprite {result.furniture.spriteId}
</p>
)}
</Link>
</li>
))}
</ul>
)}
</section>
);
}
@@ -0,0 +1,90 @@
"use client";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
import { useVisiblePolling } from "@/hooks/use-visible-polling";
import { adminFetch } from "@/lib/admin-fetch";
interface Status {
hotel: { sent: boolean; checkedAt: string; reference?: string } | null;
git: {
enabled: boolean;
pending: number;
active: number;
error?: string;
commit?: string;
};
}
export function CatalogSyncStatus({ canEdit }: { canEdit: boolean }) {
const t = useTranslations("pages.admin.catalog.workspace");
const [status, setStatus] = useState<Status | null>(null);
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
try {
const res = await adminFetch("/api/admin/catalog/status", {
cache: "no-store",
});
if (!res.ok) throw Error();
setStatus(await res.json());
setError(false);
} catch {
setError(true);
}
}, []);
useVisiblePolling(refresh, 15000);
async function retry() {
setBusy(true);
try {
const res = await adminFetch("/api/admin/catalog/status", {
method: "POST",
});
if (!res.ok) throw Error();
await refresh();
} catch {
setError(true);
} finally {
setBusy(false);
}
}
return (
<div
className="flex flex-wrap items-center gap-3 rounded-lg border border-[var(--admin-border)] p-3 text-xs"
aria-live="polite"
>
<span>
{t("hotel")}:{" "}
{status?.hotel
? t(status.hotel.sent ? "sent" : "sendFailed")
: t("unknown")}
</span>
<span>
Git:{" "}
{status
? t(
!status.git.enabled
? "disabled"
: status.git.error
? "exportFailed"
: status.git.pending || status.git.active
? "queued"
: status.git.commit
? "exported"
: "unknown",
)
: t("unknown")}
</span>
{status?.hotel?.reference && <span>{status.hotel.reference}</span>}
{canEdit && status?.hotel?.sent === false && (
<Button size="sm" variant="outline" disabled={busy} onClick={retry}>
{t("retryHotel")}
</Button>
)}
{error && (
<button type="button" onClick={() => void refresh()}>
{t("retryStatus")}
</button>
)}
</div>
);
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
assertParent,
collectSubtree,
validateSiblingOrder,
} from "./hierarchy";
const pages = [
{ id: 1, parentId: -1, orderNum: 0 },
{ id: 2, parentId: 1, orderNum: 1 },
{ id: 3, parentId: 1, orderNum: 2 },
{ id: 4, parentId: 2, orderNum: 1 },
];
describe("catalog hierarchy commands", () => {
it("rejects self, descendant, missing and malformed destinations", () => {
for (const parent of [1, 2, 4, 999, 1.5, -2])
expect(() => assertParent(pages, 1, parent)).toThrow();
});
it("allows detaching a page from an inherited cycle", () =>
expect(() =>
assertParent(
[
{ id: 1, parentId: 2 },
{ id: 2, parentId: 1 },
],
1,
-1,
),
).not.toThrow());
it("rejects a destination with an inherited parent cycle", () =>
expect(() =>
assertParent(
[
{ id: 1, parentId: -1 },
{ id: 2, parentId: 3 },
{ id: 3, parentId: 2 },
],
1,
2,
),
).toThrow());
it("collects cyclic subtrees once without recursion", () =>
expect(
collectSubtree(
[
{ id: 1, parentId: 2 },
{ id: 2, parentId: 1 },
],
1,
),
).toEqual([1, 2]));
it("rejects stale or partial sibling lists before reordering", () => {
expect(() => validateSiblingOrder(pages, 1, [2], [2, 3])).toThrow();
expect(() => validateSiblingOrder(pages, 1, [3, 2], [3, 2])).toThrow();
expect(() => validateSiblingOrder(pages, 1, [2, 2], [2, 3])).toThrow();
});
it("accepts a complete reorder matching the previous sibling order", () =>
expect(() => validateSiblingOrder(pages, 1, [3, 2], [2, 3])).not.toThrow());
});
it("orders mixed root conventions with a stable ID tie breaker", () => {
const roots = [
{ id: 3, parentId: 0, orderNum: 1 },
{ id: 2, parentId: -1, orderNum: 1 },
];
expect(() => validateSiblingOrder(roots, -1, [3, 2], [2, 3])).not.toThrow();
expect(() => validateSiblingOrder(roots, -1, [2, 3], [3, 2])).toThrow();
});
+83
View File
@@ -0,0 +1,83 @@
export interface HierarchyPage {
id: number;
parentId: number;
orderNum?: number;
}
export class CatalogConflict extends Error {}
export class CatalogInputError extends Error {}
export class CatalogNotFound extends Error {}
export function positiveId(id: number) {
if (!Number.isSafeInteger(id) || id <= 0)
throw new CatalogInputError("Invalid catalog ID");
}
export function assertParent(
pages: readonly HierarchyPage[],
pageId: number,
parentId: number,
) {
if (!Number.isSafeInteger(parentId) || parentId < -1)
throw new CatalogInputError("Invalid parent category");
const map = new Map(pages.map((page) => [page.id, page]));
const visited = new Set<number>();
let current = parentId;
while (current > 0) {
if (current === pageId || visited.has(current))
throw new CatalogInputError(
"Cannot move page: would create a circular hierarchy",
);
visited.add(current);
const parent = map.get(current);
if (!parent) throw new CatalogInputError("Parent category does not exist");
current = parent.parentId;
}
}
export function collectSubtree(
pages: readonly HierarchyPage[],
pageId: number,
): number[] {
const children = new Map<number, number[]>();
for (const page of pages) {
const list = children.get(page.parentId) ?? [];
list.push(page.id);
children.set(page.parentId, list);
}
const seen = new Set<number>();
const queue = [pageId];
for (let index = 0; index < queue.length; index++) {
const id = queue[index];
if (seen.has(id)) continue;
seen.add(id);
queue.push(...(children.get(id) ?? []));
}
return [...seen];
}
export function validateSiblingOrder(
pages: readonly HierarchyPage[],
parentId: number,
ids: readonly number[],
expected: readonly number[],
) {
const current = pages
.filter((page) =>
parentId <= 0
? page.parentId === -1 || page.parentId === 0
: page.parentId === parentId,
)
.sort((a, b) => (a.orderNum ?? 0) - (b.orderNum ?? 0) || a.id - b.id)
.map((page) => page.id);
if (
current.length !== expected.length ||
current.some((id, index) => id !== expected[index])
)
throw new CatalogConflict(
"The category order changed. Reload before trying again.",
);
if (
ids.length !== current.length ||
new Set(ids).size !== ids.length ||
ids.some((id) => !current.includes(id))
)
throw new CatalogInputError(
"A reorder must include every sibling exactly once",
);
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
numericValue,
offerPatchSchema,
parseFurnitureIds,
} from "./offer-input";
describe("offer compatibility", () => {
it.each([0, "0", -1, "-1"])(
"preserves explicit numeric value %s",
(value) => {
expect(numericValue(value, 99)).toBe(Number(value));
},
);
it.each([null, undefined, "", "invalid", Infinity])(
"defaults missing or invalid value %s",
(value) => {
expect(numericValue(value, 99)).toBe(99);
},
);
it("preserves bundle multiplicity and LTD/special offer values", () => {
expect(parseFurnitureIds("12;23;12")).toEqual([12, 23, 12]);
const values = {
itemIds: "12;23;12",
amount: 0,
offerId: -1,
orderNumber: 0,
limitedSells: 4,
limitedStack: 20,
songId: 0,
extradata: "a;b",
haveOffer: "0",
clubOnly: "1",
};
expect(offerPatchSchema.parse(values)).toEqual(values);
});
it.each(["1;0", "1;2x", "1;;2", "-1", "1.5"])(
"rejects invalid furniture references %s",
(value) => {
expect(() => parseFurnitureIds(value)).toThrow();
},
);
it("rejects invalid page ids and nonfinite prices without changing field meaning", () => {
expect(offerPatchSchema.safeParse({ pageId: 0 }).success).toBe(false);
expect(offerPatchSchema.safeParse({ costCredits: Infinity }).success).toBe(
false,
);
expect(offerPatchSchema.parse({ offerId: 0, amount: 0 })).toEqual({
offerId: 0,
amount: 0,
});
});
});
@@ -0,0 +1,83 @@
import { z } from "zod";
export const offerInteger = z.number().int().min(-2147483648).max(2147483647);
export const offerIdSchema = offerInteger.positive();
/** Missing SQL values use the field default; explicit zero and special negatives survive. */
export function numericValue(value: unknown, fallback: number): number {
if (value == null || (typeof value === "string" && value.trim() === ""))
return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function parseFurnitureIds(value: string): number[] {
const parts = value.split(";");
if (!parts.length || parts.some((part) => !/^\d+$/.test(part.trim())))
throw Error("Invalid furniture references");
return parts.map((part) => offerIdSchema.parse(Number(part)));
}
const itemIds = z
.string()
.max(666)
.refine((value) => {
try {
parseFurnitureIds(value);
return true;
} catch {
return false;
}
}, "Invalid furniture references");
// Keep signed/special values on existing offer fields: bundle/LTD meaning belongs to the emulator.
export const offerPatchSchema = z.object({
pageId: offerIdSchema.optional(),
itemIds: itemIds.optional(),
catalogName: z.string().max(100).optional(),
costCredits: offerInteger.optional(),
costPoints: offerInteger.optional(),
pointsType: offerInteger.optional(),
amount: offerInteger.optional(),
orderNumber: offerInteger.optional(),
offerId: offerInteger.optional(),
songId: z.number().int().min(0).max(4294967295).optional(),
limitedSells: offerInteger.optional(),
limitedStack: offerInteger.optional(),
extradata: z.string().max(500).optional(),
haveOffer: z.enum(["0", "1"]).optional(),
clubOnly: z.enum(["0", "1"]).optional(),
});
const flag = z.union([
z.literal(0),
z.literal(1),
z.literal("0"),
z.literal("1"),
]);
export const furniturePatchSchema = z.object({
publicName: z.string().max(56).optional(),
itemName: z.string().max(70).optional(),
type: z.string().max(3).optional(),
width: offerInteger.optional(),
length: offerInteger.optional(),
stackHeight: z.number().finite().optional(),
allowStack: flag.optional(),
allowSit: flag.optional(),
allowLay: flag.optional(),
allowWalk: flag.optional(),
allowGift: flag.optional(),
allowTrade: flag.optional(),
allowRecycle: flag.optional(),
allowMarketplaceSell: flag.optional(),
allowInventoryStack: flag.optional(),
interactionType: z.string().max(500).optional(),
interactionModesCount: offerInteger.optional(),
vendingIds: z.string().max(255).optional(),
customparams: z.string().max(25600).optional(),
effectIdMale: offerInteger.optional(),
effectIdFemale: offerInteger.optional(),
clothingOnWalk: z.string().max(255).optional(),
});
export function distinctOfferIds(ids: number[]): number[] {
for (const id of ids) offerIdSchema.parse(id);
if (new Set(ids).size !== ids.length)
throw Error("Duplicate catalog item ids");
return ids;
}
@@ -0,0 +1,27 @@
import { expect, it } from "vitest";
import { pagePatchSchema } from "./page-input";
it("preserves nullable page texts when editing unrelated fields", () => {
const fields = {
caption: "Changed",
pageText1: null,
pageText2: null,
pageTextDetails: null,
pageTextTeaser: null,
pageSpecial: null,
};
expect(pagePatchSchema.parse(fields)).toEqual(fields);
});
it("preserves valid zero values and rejects invalid flags", () => {
expect(
pagePatchSchema.parse({ parentId: 0, orderNum: 0, iconImage: 0 }),
).toEqual({ parentId: 0, orderNum: 0, iconImage: 0 });
expect(() => pagePatchSchema.parse({ enabled: "true" })).toThrow();
});
it("preserves caption whitespace for optimistic comparisons but rejects blank captions", () => {
expect(pagePatchSchema.parse({ caption: " Existing " })).toEqual({
caption: " Existing ",
});
expect(() => pagePatchSchema.parse({ caption: " " })).toThrow();
});
+31
View File
@@ -0,0 +1,31 @@
import { z } from "zod";
const flag = z.enum(["0", "1"]);
const integer = z.number().int().min(-2147483648).max(2147483647);
export const pagePatchSchema = z.object({
caption: z
.string()
.min(1)
.max(128)
.refine((value) => value.trim().length > 0, "Category name is required")
.optional(),
captionSave: z.string().max(25).optional(),
parentId: integer.min(-1).optional(),
pageLayout: z.string().min(1).max(26).optional(),
enabled: flag.optional(),
visible: flag.optional(),
clubOnly: flag.optional(),
vipOnly: flag.optional(),
minRank: integer.min(0).optional(),
orderNum: integer.optional(),
iconImage: integer.min(0).optional(),
iconColor: integer.min(0).optional(),
pageHeadline: z.string().max(1024).optional(),
pageTeaser: z.string().max(64).optional(),
pageSpecial: z.string().max(2048).nullable().optional(),
pageText1: z.string().nullable().optional(),
pageText2: z.string().nullable().optional(),
pageTextDetails: z.string().nullable().optional(),
pageTextTeaser: z.string().nullable().optional(),
includes: z.string().max(128).optional(),
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { parseCatalogSearch, validSearchQuery } from "./search";
describe("catalog search input", () => {
it("allows a one digit ID but requires at least two text characters", () => {
expect(validSearchQuery("7")).toBe(true);
expect(validSearchQuery("a")).toBe(false);
expect(validSearchQuery(" ab ")).toBe(true);
});
it("does not round oversized numeric identifiers", () => {
expect(parseCatalogSearch("normal", "9007199254740992").numericId).toBe(
null,
);
});
it("escapes LIKE wildcard and escape characters", () => {
expect(parseCatalogSearch("bc", "a_%=").pattern).toBe("a=_=%==%");
});
});
+32
View File
@@ -0,0 +1,32 @@
import { CatalogInputError } from "./hierarchy";
export type SearchCatalogKind = "normal" | "bc";
export interface CatalogSearchResult {
kind: "category" | "offer";
id: number;
pageId: number;
caption: string;
path: string[];
href: string;
furniture?: { id: number; classname: string; spriteId: number };
}
export function validSearchQuery(query: string) {
const value = query.trim();
return query.length <= 100 && (value.length >= 2 || /^\d$/.test(value));
}
export function parseCatalogSearch(kind: string, query: string) {
if (kind !== "normal" && kind !== "bc")
throw new CatalogInputError("Invalid catalog type");
if (!validSearchQuery(query))
throw new CatalogInputError(
"Search requires 2 to 100 characters or a numeric ID",
);
const value = query.trim();
const parsed = /^\d+$/.test(value) ? Number(value) : NaN;
const numericId = Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
return {
kind,
query: value,
numericId,
pattern: `${value.replace(/[=%_]/g, (char) => `=${char}`)}%`,
};
}
+28
View File
@@ -0,0 +1,28 @@
import "server-only";
import { ZodError } from "zod";
import { logger } from "@/lib/logger";
import {
CatalogConflict,
CatalogInputError,
CatalogNotFound,
} from "../domain/hierarchy";
export function catalogFailure(error: unknown) {
if (error instanceof CatalogConflict)
return { message: error.message, status: 409 };
if (error instanceof CatalogNotFound)
return { message: error.message, status: 404 };
if (error instanceof CatalogInputError)
return { message: error.message, status: 400 };
if (error instanceof ZodError)
return {
message: error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join("; "),
status: 400,
};
const reference = logger.error("Catalog operation failed", {
module: "catalog",
error,
});
return { message: `Catalog operation failed (${reference})`, status: 500 };
}
@@ -0,0 +1,319 @@
import { type SQL, sql } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
offers: [
{ id: 1, pageId: 10, itemIds: "12;23;12" },
{ id: 2, pageId: 10, itemIds: "23" },
],
pages: [{ id: 10 }, { id: 11 }],
bases: [{ id: 12 }, { id: 23 }],
queries: [] as { sql: string; params: unknown[] }[],
writes: 0,
failAt: 0,
commits: 0,
rollbacks: 0,
}));
vi.mock("@/lib/db", async () => ({
...(await import("@/db/schema")),
db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const saved = state.writes;
try {
const result = await fn({
execute: async (query: SQL) => {
const compiled = new MySqlDialect().sqlToQuery(query);
state.queries.push(compiled);
if (compiled.sql.startsWith("SELECT")) {
if (compiled.sql.includes("catalog_pages"))
return [state.pages, []];
if (compiled.sql.includes("items_base"))
return [
state.bases.filter((row) => compiled.params.includes(row.id)),
[],
];
return [
state.offers.filter((row) => compiled.params.includes(row.id)),
[],
];
}
state.writes++;
if (state.failAt === state.writes)
throw Error("database unavailable");
return [{ affectedRows: 1, insertId: 40 }, []];
},
});
state.commits++;
return result;
} catch (error) {
state.writes = saved;
state.rollbacks++;
throw error;
}
},
},
}));
import {
createBcOfferCommand,
createOfferCommand,
moveOffersCommand,
reorderOffersCommand,
updateBcOfferCommand,
updateOfferCommand,
} from "./offer-commands";
beforeEach(() => {
state.pages = [{ id: 10 }, { id: 11 }];
state.queries = [];
state.writes = 0;
state.failAt = 0;
state.commits = 0;
state.rollbacks = 0;
});
describe("transactional offer commands", () => {
it("rolls back offer and shared furniture writes together", async () => {
state.failAt = 2;
await expect(
updateOfferCommand({
id: 1,
catalogFields: { pageId: 11, costCredits: 0 },
baseItem: { id: 23, fields: { publicName: "New" } },
}),
).rejects.toThrow("database unavailable");
expect(state.writes).toBe(0);
expect(state.rollbacks).toBe(1);
});
it("prevents writing an unrelated shared furniture even when submitted as a new bundle member", async () => {
await expect(
updateOfferCommand({
id: 1,
catalogFields: { itemIds: "12;99" },
baseItem: { id: 99, fields: { publicName: "Wrong" } },
}),
).rejects.toThrow("belong");
expect(state.writes).toBe(0);
});
it("updates a non-first bundle component and preserves zero values", async () => {
await updateOfferCommand({
id: 1,
catalogFields: { pageId: 11, amount: 0, offerId: 0, orderNumber: 0 },
baseItem: { id: 23, fields: { publicName: "New" } },
});
expect(state.commits).toBe(1);
expect(state.writes).toBe(2);
expect(
state.queries.find((q) => q.sql.startsWith("UPDATE `catalog_items`"))
?.params,
).toContain("11");
});
it("rejects missing destination pages before writes", async () => {
state.pages = [];
await expect(
updateOfferCommand({ id: 1, catalogFields: { pageId: 11 } }),
).rejects.toThrow("page");
expect(state.writes).toBe(0);
});
it("rejects missing bundle references before writes", async () => {
await expect(
updateOfferCommand({ id: 1, catalogFields: { itemIds: "12;99" } }),
).rejects.toThrow("Furniture");
expect(state.writes).toBe(0);
});
it("rolls back the entire reorder after an intermediate failure", async () => {
state.failAt = 2;
await expect(
reorderOffersCommand([
{ id: 1, orderNumber: 0 },
{ id: 2, orderNumber: 1 },
]),
).rejects.toThrow("database unavailable");
expect(state.writes).toBe(0);
expect(state.rollbacks).toBe(1);
});
it("rejects duplicate reorder ids", async () => {
await expect(
reorderOffersCommand([
{ id: 1, orderNumber: 0 },
{ id: 1, orderNumber: 1 },
]),
).rejects.toThrow();
expect(state.writes).toBe(0);
});
it("rejects missing offers in a batch", async () => {
await expect(
reorderOffersCommand([
{ id: 1, orderNumber: 0 },
{ id: 99, orderNumber: 1 },
]),
).rejects.toThrow("not found");
expect(state.writes).toBe(0);
});
it("validates all source offers before moving a batch", async () => {
await expect(moveOffersCommand([1, 99], 11)).rejects.toThrow("not found");
expect(state.writes).toBe(0);
});
});
it("does not update furniture removed from the submitted bundle", async () => {
await expect(
updateOfferCommand({
id: 1,
catalogFields: { itemIds: "12" },
baseItem: { id: 23, fields: { publicName: "Wrong" } },
}),
).rejects.toThrow("belong");
expect(state.writes).toBe(0);
});
it("keeps explicit zeros in the actual offer SQL parameters", async () => {
await updateOfferCommand({
id: 1,
catalogFields: {
amount: 0,
orderNumber: 0,
offerId: 0,
limitedSells: 0,
limitedStack: 0,
},
});
const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
expect(update?.params).toEqual([0, 0, 0, 0, 0, 1]);
});
it("rejects unsafe ids before opening a transaction", async () => {
await expect(
updateOfferCommand({ id: 0, catalogFields: { amount: 1 } }),
).rejects.toThrow();
await expect(
reorderOffersCommand([{ id: 1.5, orderNumber: 1 }]),
).rejects.toThrow();
expect(state.queries).toHaveLength(0);
});
it("uses one atomic update and a string page parameter for a move", async () => {
await moveOffersCommand([1, 2], 11);
expect(state.commits).toBe(1);
expect(state.writes).toBe(1);
expect(
state.queries.find((query) => query.sql.startsWith("UPDATE"))?.params,
).toEqual(["11", 1, 2]);
});
it("ignores noneditable furniture and offer fields", async () => {
await expect(
updateOfferCommand({
id: 1,
catalogFields: { id: 2 },
baseItem: { id: 12, fields: { spriteId: 99 } },
}),
).rejects.toThrow("No valid fields");
expect(state.queries).toHaveLength(0);
});
// Creation must retain the parent lock until its insert commits.
it.each(["normal", "bc"] as const)(
"rejects a deleted parent before a %s creation callback runs",
async (kind) => {
state.pages = [];
const insert = vi.fn();
await expect(
createOfferCommand(kind, { pageId: 11, itemIds: "12;23" }, insert),
).rejects.toThrow("page");
expect(insert).not.toHaveBeenCalled();
expect(state.writes).toBe(0);
},
);
it("rejects missing furniture before creating an offer", async () => {
const insert = vi.fn();
await expect(
createOfferCommand("normal", { pageId: 11, itemIds: "12;99" }, insert),
).rejects.toThrow("Furniture");
expect(insert).not.toHaveBeenCalled();
});
it("runs creation insert under the same transaction after reference locks", async () => {
await createOfferCommand(
"normal",
{ pageId: 11, itemIds: "12;23;12" },
async (tx) => {
await tx.execute(sql`INSERT INTO catalog_items (id) VALUES (40)`);
return 40;
},
);
expect(state.queries.map((query) => query.sql.split(" ")[0])).toEqual([
"SELECT",
"SELECT",
"INSERT",
]);
expect(state.queries[0].sql).toContain("FOR UPDATE");
expect(state.queries[1].sql).toContain("FOR UPDATE");
expect(state.commits).toBe(1);
});
it("rolls back a failed creation insert", async () => {
state.failAt = 1;
await expect(
createOfferCommand("normal", { pageId: 11, itemIds: "12" }, (tx) =>
tx.execute(sql`INSERT INTO catalog_items (id) VALUES (40)`),
),
).rejects.toThrow("database unavailable");
expect(state.rollbacks).toBe(1);
expect(state.writes).toBe(0);
});
it("rejects missing BC offers instead of reporting a zero-row update as saved", async () => {
await expect(
updateBcOfferCommand(99, { catalogName: "Missing" }),
).rejects.toThrow("not found");
expect(state.writes).toBe(0);
});
it("keeps BC fields explicit and uses BC destination locks", async () => {
await updateBcOfferCommand(1, {
pageId: 11,
itemIds: "12;23",
orderNumber: 0,
costCredits: 99,
});
expect(state.queries[0].sql).toContain("catalog_pages_bc");
const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
expect(update?.sql).toContain("catalog_items_bc");
expect(update?.sql).not.toContain("cost_credits");
expect(update?.params).toEqual(["11", "12;23", 0, 1]);
});
it("creates a BC offer with only its supported fields and returns the committed id", async () => {
const id = await createBcOfferCommand({
pageId: 11,
itemIds: "12;23;12",
catalogName: "Bundle",
orderNumber: 0,
extradata: "x;y",
costCredits: 99,
});
expect(id).toBe(40);
expect(state.commits).toBe(1);
const insert = state.queries.find((query) => query.sql.startsWith("INSERT"));
expect(insert?.sql).toContain("catalog_items_bc");
expect(insert?.sql).not.toContain("cost_credits");
expect(insert?.params).toEqual(["11", "12;23;12", "Bundle", 0, "x;y"]);
});
it("rejects incomplete BC creation before beginning a transaction", async () => {
await expect(
createBcOfferCommand({ pageId: 11, itemIds: "12" }),
).rejects.toThrow();
expect(state.queries).toHaveLength(0);
});
it("rolls back failed BC creation and does not return an id", async () => {
state.failAt = 1;
await expect(
createBcOfferCommand({
pageId: 11,
itemIds: "12",
catalogName: "One",
orderNumber: 0,
extradata: "",
}),
).rejects.toThrow("database unavailable");
expect(state.commits).toBe(0);
expect(state.rollbacks).toBe(1);
});
it("rejects a missing BC destination before touching the offer", async () => {
state.pages = [];
await expect(updateBcOfferCommand(1, { pageId: 11 })).rejects.toThrow("page");
expect(state.writes).toBe(0);
});
@@ -0,0 +1,206 @@
import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
import {
distinctOfferIds,
furniturePatchSchema,
offerIdSchema,
offerInteger,
offerPatchSchema,
parseFurnitureIds,
} from "../domain/offer-input";
type OfferKind = "normal" | "bc";
function offerTable(kind: OfferKind) {
if (kind !== "normal" && kind !== "bc") throw Error("Invalid catalog type");
return kind === "bc" ? CatalogItemsBc : CatalogItems;
}
type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
export type UpdateOfferInput = {
id: number;
catalogFields: Record<string, unknown>;
baseItem?: { id: number; fields: Record<string, unknown> };
};
type OfferRow = { id: number; pageId: number | string; itemIds: string };
async function lockedOffers(
tx: Transaction,
ids: number[],
kind: OfferKind = "normal",
): Promise<OfferRow[]> {
const [rows] = await tx.execute(
sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join(
[...ids].sort((a, b) => a - b),
sql`, `,
)}) ORDER BY id FOR UPDATE`,
);
const offers = rows as unknown as OfferRow[];
if (
offers.length !== ids.length ||
ids.some((id) => !offers.some((offer) => Number(offer.id) === id))
)
throw Error("Catalog item not found");
return offers;
}
async function lockPage(
tx: Transaction,
id: number,
kind: OfferKind = "normal",
) {
offerTable(kind);
const [rows] = await tx.execute(
sql`SELECT id FROM ${sql.identifier(kind === "bc" ? "catalog_pages_bc" : "catalog_pages")} WHERE id=${id} FOR UPDATE`,
);
if (
!(rows as unknown as { id: number }[]).some((row) => Number(row.id) === id)
)
throw Error("Catalog page not found");
}
async function lockFurniture(tx: Transaction, ids: number[]) {
const unique = [...new Set(ids)].sort((a, b) => a - b);
const [rows] = await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(unique, sql`, `)}) ORDER BY id FOR UPDATE`,
);
const found = new Set(
(rows as unknown as { id: number }[]).map((row) => Number(row.id)),
);
if (unique.some((id) => !found.has(id)))
throw Error("Furniture reference not found");
}
function assignments(
table: typeof CatalogItems | typeof CatalogItemsBc | typeof ItemsBase,
fields: Record<string, unknown>,
): SQL[] {
const columns = getTableColumns(table);
return Object.entries(fields)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => {
const column = columns[key as keyof typeof columns];
// SQL string parameters work with both deployed page_id column types.
return sql`${sql.identifier(column.name)}=${key === "pageId" ? String(value) : value}`;
});
}
export async function updateOfferCommand(input: UpdateOfferInput) {
offerIdSchema.parse(input.id);
const fields = offerPatchSchema.parse(input.catalogFields);
const baseFields = input.baseItem
? furniturePatchSchema.parse(input.baseItem.fields)
: {};
if (input.baseItem) offerIdSchema.parse(input.baseItem.id);
if (
!Object.values(fields).some((value) => value !== undefined) &&
!Object.values(baseFields).some((value) => value !== undefined)
)
throw Error("No valid fields to update");
return db.transaction(async (tx) => {
// Page locks precede offer locks, matching category deletion's lock order.
if (fields.pageId !== undefined) await lockPage(tx, fields.pageId);
const [offer] = await lockedOffers(tx, [input.id]);
if (input.baseItem) {
const currentIds = String(offer.itemIds).split(";").map(Number);
const nextIds =
fields.itemIds === undefined
? currentIds
: parseFurnitureIds(fields.itemIds);
if (
!currentIds.includes(input.baseItem.id) ||
!nextIds.includes(input.baseItem.id)
)
throw Error("Shared furniture must belong to the catalog offer");
}
const references =
fields.itemIds === undefined ? [] : parseFurnitureIds(fields.itemIds);
if (input.baseItem) references.push(input.baseItem.id);
if (references.length) await lockFurniture(tx, references);
const offerAssignments = assignments(CatalogItems, fields);
if (offerAssignments.length)
await tx.execute(
sql`UPDATE ${CatalogItems} SET ${sql.join(offerAssignments, sql`, `)} WHERE id=${input.id}`,
);
const baseAssignments = assignments(ItemsBase, baseFields);
if (input.baseItem && baseAssignments.length)
await tx.execute(
sql`UPDATE ${ItemsBase} SET ${sql.join(baseAssignments, sql`, `)} WHERE id=${input.baseItem.id}`,
);
});
}
export async function reorderOffersCommand(
orders: Array<{ id: number; orderNumber: number }>,
) {
const ids = distinctOfferIds(orders.map((row) => row.id));
for (const row of orders) offerInteger.parse(row.orderNumber);
if (!ids.length) return;
return db.transaction(async (tx) => {
await lockedOffers(tx, ids);
for (const row of orders)
await tx.execute(
sql`UPDATE ${CatalogItems} SET order_number=${row.orderNumber} WHERE id=${row.id}`,
);
});
}
export async function moveOffersCommand(ids: number[], targetPageId: number) {
distinctOfferIds(ids);
offerIdSchema.parse(targetPageId);
if (!ids.length) return;
return db.transaction(async (tx) => {
await lockPage(tx, targetPageId);
await lockedOffers(tx, ids);
await tx.execute(
sql`UPDATE ${CatalogItems} SET page_id=${String(targetPageId)} WHERE id IN (${sql.join(ids, sql`, `)})`,
);
});
}
/** Call the allocator before entering this command; only the insert uses its transaction. */
export async function createOfferCommand<T>(
kind: OfferKind,
references: { pageId: number; itemIds: string },
insert: (tx: Transaction) => Promise<T>,
): Promise<T> {
offerTable(kind);
offerIdSchema.parse(references.pageId);
const ids = parseFurnitureIds(references.itemIds);
return db.transaction(async (tx) => {
await lockPage(tx, references.pageId, kind);
await lockFurniture(tx, ids);
return insert(tx);
});
}
// BC deliberately has no currencies, amount, LTD values or shared-furniture mutation.
const bcOfferPatchSchema = offerPatchSchema.pick({
pageId: true,
itemIds: true,
catalogName: true,
orderNumber: true,
extradata: true,
});
export async function updateBcOfferCommand(
id: number,
input: Record<string, unknown>,
) {
offerIdSchema.parse(id);
const fields = bcOfferPatchSchema.parse(input);
const updates = assignments(CatalogItemsBc, fields);
if (!updates.length) throw Error("No valid fields to update");
return db.transaction(async (tx) => {
if (fields.pageId !== undefined) await lockPage(tx, fields.pageId, "bc");
await lockedOffers(tx, [id], "bc");
if (fields.itemIds !== undefined)
await lockFurniture(tx, parseFurnitureIds(fields.itemIds));
await tx.execute(
sql`UPDATE ${CatalogItemsBc} SET ${sql.join(updates, sql`, `)} WHERE id=${id}`,
);
});
}
export async function createBcOfferCommand(
input: Record<string, unknown>,
): Promise<number> {
const fields = bcOfferPatchSchema.required().parse(input);
return createOfferCommand("bc", fields, async (tx) => {
const [result] = await tx.execute(
sql`INSERT INTO ${CatalogItemsBc} SET ${sql.join(assignments(CatalogItemsBc, fields), sql`, `)}`,
);
const id = Number((result as unknown as { insertId: number }).insertId);
offerIdSchema.parse(id);
return id;
});
}
+38
View File
@@ -0,0 +1,38 @@
import "server-only";
import { asc, count, eq, or } from "drizzle-orm";
import {
CatalogItems,
CatalogItemsBc,
CatalogPages,
CatalogPagesBc,
db,
} from "@/lib/db";
import type { CatalogKind } from "./page-commands";
export async function loadCatalogOverview(kind: CatalogKind) {
const pages = kind === "bc" ? CatalogPagesBc : CatalogPages;
const items = kind === "bc" ? CatalogItemsBc : CatalogItems;
const [roots, pageCount, itemCount, enabledCount] = await Promise.all([
db
.select({
id: pages.id,
caption: pages.caption,
iconImage: pages.iconImage,
enabled: pages.enabled,
orderNum: pages.orderNum,
})
.from(pages)
.where(or(eq(pages.parentId, -1), eq(pages.parentId, 0)))
.orderBy(asc(pages.orderNum), asc(pages.id)),
db.select({ total: count() }).from(pages),
db.select({ total: count() }).from(items),
db.select({ total: count() }).from(pages).where(eq(pages.enabled, "1")),
]);
return {
roots,
totals: {
totalPages: Number(pageCount[0]?.total ?? 0),
totalItems: Number(itemCount[0]?.total ?? 0),
enabledPages: Number(enabledCount[0]?.total ?? 0),
},
};
}
@@ -0,0 +1,120 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
rows: [
{ id: 1, parentId: -1, orderNum: 1, caption: "Current" },
{ id: 2, parentId: 1, orderNum: 1 },
{ id: 3, parentId: 1, orderNum: 2 },
],
queries: [] as string[],
writes: 0,
failAt: 0,
commits: 0,
rollbacks: 0,
}));
vi.mock("@/lib/db", async () => ({
...(await import("@/db/schema")),
db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const saved = state.writes;
try {
const result = await fn({
execute: async (query: SQL) => {
const text = new MySqlDialect().sqlToQuery(query).sql;
state.queries.push(text);
if (text.startsWith("SELECT")) return [state.rows, []];
state.writes++;
if (state.failAt === state.writes)
throw Error("database unavailable");
return [{ insertId: 4, affectedRows: 1 }, []];
},
});
state.commits++;
return result;
} catch (error) {
state.writes = saved;
state.rollbacks++;
throw error;
}
},
},
}));
import {
deletePageCommand,
reorderPagesCommand,
updatePageCommand,
} from "./page-commands";
beforeEach(() => {
state.queries = [];
state.writes = 0;
state.failAt = 0;
state.commits = 0;
state.rollbacks = 0;
});
describe.each(["normal", "bc"] as const)(
"%s transactional commands",
(kind) => {
it("rejects stale category fields without overwriting another operator", async () => {
await expect(
updatePageCommand(kind, 1, { caption: "Mine" }, { caption: "Old" }),
).rejects.toThrow("another operator");
expect(state.writes).toBe(0);
});
it("accepts matching category fields", async () => {
await updatePageCommand(
kind,
1,
{ caption: "Mine" },
{ caption: "Current" },
);
expect(state.commits).toBe(1);
});
it("rolls back an interrupted reorder", async () => {
state.failAt = 2;
await expect(
reorderPagesCommand(kind, {
parentId: 1,
ids: [3, 2],
expectedIds: [2, 3],
}),
).rejects.toThrow("database unavailable");
expect(state.writes).toBe(0);
expect(state.rollbacks).toBe(1);
expect(state.queries[0]).toContain("ORDER BY id FOR UPDATE");
});
it("rejects stale reorder before any writes", async () => {
await expect(
reorderPagesCommand(kind, {
parentId: 1,
ids: [3, 2],
expectedIds: [3, 2],
}),
).rejects.toThrow("changed");
expect(state.writes).toBe(0);
});
it("rejects a cyclic parent through ordinary page editing", async () => {
await expect(updatePageCommand(kind, 1, { parentId: 2 })).rejects.toThrow(
"circular",
);
expect(state.writes).toBe(0);
});
it("rolls back reparent plus delete if the offer delete fails", async () => {
state.failAt = 2;
await expect(deletePageCommand(kind, 2, "reparent")).rejects.toThrow();
expect(state.writes).toBe(0);
expect(state.commits).toBe(0);
});
it("commits the entire subtree delete once", async () => {
expect(await deletePageCommand(kind, 1, "cascade")).toEqual({
deletedPages: 3,
movedChildren: 0,
});
expect(state.commits).toBe(1);
expect(state.writes).toBe(2);
});
},
);
@@ -0,0 +1,169 @@
import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { CatalogPages, CatalogPagesBc, db } from "@/lib/db";
import {
assertParent,
CatalogConflict,
CatalogInputError,
CatalogNotFound,
collectSubtree,
type HierarchyPage,
positiveId,
validateSiblingOrder,
} from "../domain/hierarchy";
import { pagePatchSchema } from "../domain/page-input";
export type CatalogKind = "normal" | "bc";
type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
function tables(kind: CatalogKind) {
if (kind !== "normal" && kind !== "bc")
throw new CatalogInputError("Invalid catalog type");
return {
pages: kind === "bc" ? CatalogPagesBc : CatalogPages,
items: sql.identifier(kind === "bc" ? "catalog_items_bc" : "catalog_items"),
};
}
async function lockedPages(
tx: Transaction,
kind: CatalogKind,
): Promise<HierarchyPage[]> {
const { pages } = tables(kind);
// Structural commands serialize against a stable lock order, including parent validation.
const [rows] = await tx.execute(
sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM ${pages} ORDER BY id FOR UPDATE`,
);
return (rows as unknown as HierarchyPage[]).map((row) => ({
id: Number(row.id),
parentId: Number(row.parentId),
orderNum: Number(row.orderNum),
}));
}
function fieldsSql(kind: CatalogKind, fields: Record<string, unknown>): SQL[] {
const columns = getTableColumns(tables(kind).pages);
return Object.entries(fields)
.filter(
([key, value]) => value !== undefined && Object.hasOwn(columns, key),
)
.map(
([key, value]) =>
sql`${sql.identifier(columns[key as keyof typeof columns].name)} = ${value}`,
);
}
export async function updatePageCommand(
kind: CatalogKind,
id: number,
fields: Record<string, unknown>,
expected?: Record<string, unknown>,
) {
positiveId(id);
const data = pagePatchSchema.parse(fields);
return db.transaction(async (tx) => {
const rows = await lockedPages(tx, kind);
if (!rows.some((row) => row.id === id))
throw new CatalogNotFound("Catalog page not found");
if (expected) {
const columns = getTableColumns(tables(kind).pages);
const [existingRows] = await tx.execute(
sql`SELECT * FROM ${tables(kind).pages} WHERE id=${id} FOR UPDATE`,
);
const current = (existingRows as unknown as Record<string, unknown>[])[0];
if (!current) throw new CatalogNotFound("Catalog page not found");
for (const [key, value] of Object.entries(expected)) {
if (!Object.hasOwn(data, key) || !Object.hasOwn(columns, key)) continue;
const column = columns[key as keyof typeof columns];
if (String(current[column.name] ?? "") !== String(value ?? ""))
throw new CatalogConflict(
"This category was modified by another operator. Reload before saving.",
);
}
}
if (data.parentId !== undefined) assertParent(rows, id, data.parentId);
const assignments = fieldsSql(kind, data);
if (!assignments.length)
throw new CatalogInputError("No valid fields to update");
await tx.execute(
sql`UPDATE ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)} WHERE id=${id}`,
);
});
}
export async function createPageCommand(
kind: CatalogKind,
fields: Record<string, unknown>,
): Promise<number> {
const data = pagePatchSchema.parse(fields);
if (!data.caption) throw new CatalogInputError("Category name is required");
return db.transaction(async (tx) => {
const rows = await lockedPages(tx, kind);
assertParent(rows, 0, data.parentId ?? -1);
const assignments = fieldsSql(kind, {
...data,
captionSave: data.captionSave ?? data.caption?.slice(0, 25),
});
const [result] = await tx.execute(
sql`INSERT INTO ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)}`,
);
return Number((result as unknown as { insertId: number }).insertId);
});
}
export async function reorderPagesCommand(
kind: CatalogKind,
input: { parentId: number; ids: number[]; expectedIds: number[] },
) {
return db.transaction(async (tx) => {
const rows = await lockedPages(tx, kind);
validateSiblingOrder(rows, input.parentId, input.ids, input.expectedIds);
for (const [index, id] of input.ids.entries())
await tx.execute(
sql`UPDATE ${tables(kind).pages} SET order_num=${index + 1} WHERE id=${id}`,
);
});
}
export async function deletePageCommand(
kind: CatalogKind,
id: number,
mode: "reparent" | "cascade",
) {
positiveId(id);
if (mode !== "reparent" && mode !== "cascade")
throw new CatalogInputError("Invalid delete mode");
return db.transaction(async (tx) => {
const rows = await lockedPages(tx, kind);
const page = rows.find((row) => row.id === id);
if (!page) return { deletedPages: 0, movedChildren: 0 };
const { pages, items } = tables(kind);
let ids = [id];
let movedChildren = 0;
if (mode === "reparent") {
assertParent(rows, id, page.parentId);
movedChildren = rows.filter((row) => row.parentId === id).length;
await tx.execute(
sql`UPDATE ${pages} SET parent_id=${page.parentId} WHERE parent_id=${id}`,
);
} else ids = collectSubtree(rows, id);
// String literals match INT and VARCHAR page_id installations without casting the indexed column.
await tx.execute(
sql`DELETE FROM ${items} WHERE page_id IN (${sql.join(ids.map(String), sql`, `)})`,
);
await tx.execute(
sql`DELETE FROM ${pages} WHERE id IN (${sql.join(ids, sql`, `)})`,
);
return { deletedPages: ids.length, movedChildren };
});
}
export async function togglePageCommand(
kind: CatalogKind,
id: number,
field: "enabled" | "visible",
) {
positiveId(id);
if (field !== "enabled" && field !== "visible")
throw new CatalogInputError("Invalid toggle field");
return db.transaction(async (tx) => {
const rows = await lockedPages(tx, kind);
if (!rows.some((row) => row.id === id))
throw new CatalogNotFound("Catalog page not found");
const column = sql.identifier(field);
await tx.execute(
sql`UPDATE ${tables(kind).pages} SET ${column}=CASE WHEN ${column}='1' THEN '0' ELSE '1' END WHERE id=${id}`,
);
});
}
@@ -0,0 +1,47 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
reorder: vi.fn(),
send: vi.fn(),
permission: vi.fn(),
}));
vi.mock("@/features/catalog/server/page-commands", () => ({
reorderPagesCommand: state.reorder,
}));
vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: state.send,
}));
vi.mock("@/features/catalog/server/errors", () => ({
catalogFailure: vi.fn(),
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.permission }));
vi.mock("@/lib/permissions", () => ({
PERMS: { CATALOG_EDIT: "catalog.edit" },
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: async (fn: () => unknown) => fn(),
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { reorderCatalogPages } from "@/actions/catalog";
beforeEach(() => {
vi.clearAllMocks();
state.reorder.mockResolvedValue(undefined);
state.permission.mockResolvedValue({ id: 1 });
});
it("requests only one hotel update after a complete sibling reorder", async () => {
const input = { parentId: 1, ids: [3, 2], expectedIds: [2, 3] };
await reorderCatalogPages(input);
expect(state.reorder).toHaveBeenCalledWith("normal", input);
expect(state.send).toHaveBeenCalledOnce();
expect(state.permission).toHaveBeenCalledWith("catalog.edit");
});
it("does not request a hotel update for a rejected reorder", async () => {
state.reorder.mockRejectedValueOnce(Error("conflict"));
await expect(
reorderCatalogPages({ parentId: 1, ids: [3, 2], expectedIds: [2, 3] }),
).rejects.toThrow();
expect(state.send).not.toHaveBeenCalled();
});
+136
View File
@@ -0,0 +1,136 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
queries: [] as { sql: string; params: unknown[] }[],
categories: [] as unknown[],
furniture: [] as unknown[],
offers: [] as unknown[],
parents: [] as unknown[],
}));
vi.mock("@/lib/db", () => ({
db: {
execute: vi.fn(async (query: SQL) => {
const result = new MySqlDialect().sqlToQuery(query);
state.queries.push(result);
if (result.sql.includes("FROM items_base")) return [state.furniture];
if (result.sql.includes("SELECT i.id")) return [state.offers];
if (result.sql.includes("WHERE id IN")) return [state.parents];
return [state.categories];
}),
},
}));
import { searchCatalog } from "./search";
beforeEach(() => {
state.queries = [];
state.categories = [];
state.furniture = [];
state.offers = [];
state.parents = [];
});
describe("bounded catalog search", () => {
it.each(["", "a", " ", "x".repeat(101)])(
"rejects invalid query before SQL: %s",
async (query) => {
await expect(searchCatalog("normal", query)).rejects.toThrow(
"Search requires",
);
expect(state.queries).toHaveLength(0);
},
);
it("rejects an unknown catalog instead of interpolating a table name", async () => {
await expect(searchCatalog("other", "chair")).rejects.toThrow(
"Invalid catalog type",
);
expect(state.queries).toHaveLength(0);
});
it("binds text and treats user wildcard characters literally", async () => {
await searchCatalog("normal", "chair_%' OR 1=1");
expect(state.queries.every((query) => !query.sql.includes("chair"))).toBe(
true,
);
expect(state.queries[0].params).toContain("chair=_=%' OR 1==1%");
expect(state.queries.every((query) => query.sql.includes("LIMIT"))).toBe(
true,
);
});
it.each(["normal", "bc"])(
"finds numeric IDs and resolves bounded category paths for %s",
async (kind) => {
state.furniture = [{ id: 7, classname: "chair", spriteId: 9 }];
state.offers = [
{
id: 50,
caption: "Offer",
pageId: "11",
pageCaption: "Chairs",
parentId: 1,
itemIds: "3;7",
},
];
state.parents = [{ id: 1, caption: "Shop", parentId: -1 }];
const results = await searchCatalog(kind, "9");
expect(results).toEqual([
{
kind: "offer",
id: 50,
pageId: 11,
caption: "Offer",
path: ["Shop", "Chairs"],
href:
kind === "bc"
? "/admin/catalog/builder-club/11"
: "/admin/catalog/11",
furniture: { id: 7, classname: "chair", spriteId: 9 },
},
]);
const offers = state.queries.find((query) =>
query.sql.includes("SELECT i.id"),
);
expect(offers?.sql.includes("i.offer_id")).toBe(kind === "normal");
expect(offers?.sql).toContain(
kind === "bc" ? "catalog_items_bc" : "catalog_items",
);
expect(offers?.params).toContain("7");
expect(offers?.params).toContain("9");
expect(state.queries).toHaveLength(4);
},
);
it("caps final results and furniture bundle predicates even if a repository overreturns", async () => {
state.categories = Array.from({ length: 40 }, (_, index) => ({
id: index + 1,
caption: "Category",
parentId: -1,
}));
state.furniture = Array.from({ length: 80 }, (_, index) => ({
id: index + 1,
classname: "chair",
spriteId: index,
}));
state.offers = Array.from({ length: 80 }, (_, index) => ({
id: index + 1,
caption: "Offer",
pageId: 1,
pageCaption: "Category",
parentId: -1,
itemIds: "1",
}));
const results = await searchCatalog("normal", "chair");
expect(results).toHaveLength(25);
const query = state.queries.find((query) =>
query.sql.includes("SELECT i.id"),
);
expect(query?.sql.match(/FIND_IN_SET/g)).toHaveLength(25);
expect(query?.params.at(-1)).toBe(13);
});
it("terminates corrupt cyclic category paths without repeated parent queries", async () => {
state.categories = [{ id: 1, caption: "One", parentId: 2 }];
state.parents = [{ id: 2, caption: "Two", parentId: 1 }];
const results = await searchCatalog("normal", "One");
expect(results[0].path).toEqual(["Two", "One"]);
expect(state.queries).toHaveLength(4);
});
});
+149
View File
@@ -0,0 +1,149 @@
import "server-only";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { type CatalogSearchResult, parseCatalogSearch } from "../domain/search";
const LIMIT = 25;
const MAX_PATH_DEPTH = 16;
type PageRow = { id: number; caption: string; parentId: number };
type FurnitureRow = { id: number; classname: string; spriteId: number };
type OfferRow = {
id: number;
caption: string;
pageId: number;
pageCaption: string;
parentId: number;
itemIds: string;
};
export async function searchCatalog(
kind: string,
query: string,
): Promise<CatalogSearchResult[]> {
const input = parseCatalogSearch(kind, query);
const pages = sql.identifier(
input.kind === "bc" ? "catalog_pages_bc" : "catalog_pages",
);
const offers = sql.identifier(
input.kind === "bc" ? "catalog_items_bc" : "catalog_items",
);
const number = input.numericId;
const [categoryData, furnitureData] = await Promise.all([
db.execute(
sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE caption LIKE ${input.pattern} ESCAPE '=' ${number === null ? sql`` : sql`OR id=${number}`} ORDER BY ${number === null ? sql`id` : sql`CASE WHEN id=${number} THEN 0 ELSE 1 END, id`} LIMIT 12`,
),
db.execute(
sql`SELECT id, item_name AS classname, sprite_id AS spriteId FROM items_base WHERE item_name LIKE ${input.pattern} ESCAPE '=' OR public_name LIKE ${input.pattern} ESCAPE '=' ${number === null ? sql`` : sql`OR id=${number} OR sprite_id=${number}`} ORDER BY ${number === null ? sql`id` : sql`CASE WHEN id=${number} THEN 0 ELSE 1 END, id`} LIMIT 25`,
),
]);
const categories = (categoryData[0] as unknown as PageRow[]).slice(0, 12);
const furniture = (furnitureData[0] as unknown as FurnitureRow[]).slice(
0,
LIMIT,
);
const predicates = [sql`i.catalog_name LIKE ${input.pattern} ESCAPE '='`];
if (number !== null) {
predicates.push(sql`i.id=${number}`, sql`i.page_id=${String(number)}`);
if (input.kind === "normal") predicates.push(sql`i.offer_id=${number}`);
}
// The legacy semicolon relation has no join index. Cap candidate furniture IDs before testing bundle membership.
for (const base of furniture)
predicates.push(
sql`FIND_IN_SET(${String(base.id)}, REPLACE(i.item_ids, ';', ',')) > 0`,
);
const [offerData] = await db.execute(
sql`SELECT i.id, i.catalog_name AS caption, i.page_id AS pageId, i.item_ids AS itemIds, p.caption AS pageCaption, p.parent_id AS parentId FROM ${offers} i INNER JOIN ${pages} p ON p.id=i.page_id WHERE ${sql.join(predicates, sql` OR `)} ORDER BY ${number === null ? sql`i.id` : sql`CASE WHEN i.id=${number} THEN 0 ELSE 1 END, i.id`} LIMIT ${LIMIT - categories.length}`,
);
const matches = (offerData as unknown as OfferRow[]).slice(
0,
LIMIT - categories.length,
);
const pageMap = new Map<number, PageRow>();
for (const page of categories)
pageMap.set(Number(page.id), {
...page,
id: Number(page.id),
parentId: Number(page.parentId),
});
for (const offer of matches)
pageMap.set(Number(offer.pageId), {
id: Number(offer.pageId),
caption: offer.pageCaption,
parentId: Number(offer.parentId),
});
let frontier = [...pageMap.values()].map((page) => page.parentId);
const attempted = new Set(pageMap.keys());
for (let depth = 0; depth < MAX_PATH_DEPTH; depth++) {
const ids = [...new Set(frontier)]
.filter((id) => id > 0 && !attempted.has(id))
.slice(0, LIMIT);
if (!ids.length) break;
for (const id of ids) attempted.add(id);
const [parents] = await db.execute(
sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE id IN (${sql.join(ids, sql`, `)}) LIMIT 25`,
);
frontier = [];
for (const raw of parents as unknown as PageRow[]) {
const page = {
...raw,
id: Number(raw.id),
parentId: Number(raw.parentId),
};
pageMap.set(page.id, page);
frontier.push(page.parentId);
}
}
function pagePath(id: number) {
const path: string[] = [];
const seen = new Set<number>();
let current = id;
while (current > 0 && !seen.has(current) && seen.size <= MAX_PATH_DEPTH) {
seen.add(current);
const page = pageMap.get(current);
if (!page) {
path.unshift(`#${current}`);
break;
}
path.unshift(page.caption);
current = page.parentId;
}
return path;
}
const href = (id: number) =>
input.kind === "bc"
? `/admin/catalog/builder-club/${id}`
: `/admin/catalog/${id}`;
return [
...categories.map(
(page): CatalogSearchResult => ({
kind: "category",
id: Number(page.id),
pageId: Number(page.id),
caption: page.caption,
path: pagePath(Number(page.id)),
href: href(Number(page.id)),
}),
),
...matches.map((offer): CatalogSearchResult => {
const ids = new Set(String(offer.itemIds).split(";").map(Number));
const base = furniture.find((item) => ids.has(Number(item.id)));
return {
kind: "offer",
id: Number(offer.id),
pageId: Number(offer.pageId),
caption: offer.caption,
path: pagePath(Number(offer.pageId)),
href: href(Number(offer.pageId)),
...(base
? {
furniture: {
id: Number(base.id),
classname: base.classname,
spriteId: Number(base.spriteId),
},
}
: {}),
};
}),
];
}
@@ -0,0 +1,42 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
root: "",
send: vi.fn(),
log: vi.fn(() => "catalog-error-1"),
}));
vi.mock("@/lib/services/catalog-git-config", () => ({
catalogStateRoot: () => state.root,
}));
vi.mock("@/lib/services/rcon", () => ({ rcon: { updateCatalog: state.send } }));
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
import { readCatalogHotelStatus, sendCatalogUpdate } from "./sync-status";
beforeEach(async () => {
state.root = await fs.mkdtemp(path.join(os.tmpdir(), "catalog-status-"));
vi.clearAllMocks();
});
afterEach(async () => {
await fs.rm(state.root, { recursive: true, force: true });
});
it("records a failed delivery without rejecting an already saved operation", async () => {
state.send.mockResolvedValue(false);
const result = await sendCatalogUpdate();
expect(result.sent).toBe(false);
expect(result.reference).toBe("catalog-error-1");
expect(await readCatalogHotelStatus()).toEqual(result);
});
it("allows retrying the update without reapplying catalog mutations", async () => {
state.send
.mockRejectedValueOnce(Error("offline"))
.mockResolvedValueOnce(true);
expect((await sendCatalogUpdate()).sent).toBe(false);
expect((await sendCatalogUpdate()).sent).toBe(true);
expect((await readCatalogHotelStatus())?.sent).toBe(true);
});
it("returns no status before the first attempt", async () =>
expect(await readCatalogHotelStatus()).toBeNull());
@@ -0,0 +1,59 @@
import "server-only";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { logger } from "@/lib/logger";
import { catalogStateRoot } from "@/lib/services/catalog-git-config";
import { rcon } from "@/lib/services/rcon";
export interface CatalogHotelStatus {
sent: boolean;
checkedAt: string;
reference?: string;
}
export async function readCatalogHotelStatus(): Promise<CatalogHotelStatus | null> {
try {
return JSON.parse(
await fs.readFile(
path.join(catalogStateRoot(), "hotel-status.json"),
"utf8",
),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
/** An acknowledged socket write is an update request, not proof of client application. */
export async function sendCatalogUpdate(): Promise<CatalogHotelStatus> {
let sent = false;
let reference: string | undefined;
try {
sent = await rcon.updateCatalog();
if (!sent)
reference = logger.error(
"Catalog saved; hotel update was not delivered",
{ module: "catalog" },
);
} catch (error) {
reference = logger.error("Catalog saved; hotel update failed", {
module: "catalog",
error,
});
}
const status = { sent, checkedAt: new Date().toISOString(), reference };
const root = catalogStateRoot();
const temp = path.join(root, `hotel-${randomUUID()}.tmp`);
try {
await fs.mkdir(root, { recursive: true });
await fs.writeFile(temp, JSON.stringify(status), { flag: "wx" });
await fs.rename(temp, path.join(root, "hotel-status.json"));
} catch (error) {
logger.error("Cannot persist catalog hotel update status", {
module: "catalog",
error,
});
} finally {
await fs.rm(temp, { force: true }).catch(() => {});
}
return status;
}
@@ -0,0 +1,43 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ finish: vi.fn(), log: vi.fn() }));
vi.mock("./catalog-git-config", () => ({
readManagedCatalogConfig: () => ({ enabled: true }),
catalogStateRoot: () => "unused",
}));
vi.mock("./catalog-git-core", () => ({
CatalogExportQueue: class {
async begin() {
return state.finish;
}
},
}));
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
import { isCatalogMutation, withCatalogExport } from "./catalog-git-queue";
beforeEach(() => {
vi.clearAllMocks();
state.finish.mockRejectedValue(Error("disk full"));
});
it("does not turn a committed create into a failed action when queue completion fails", async () => {
expect(await withCatalogExport(async () => ({ ok: true, id: 42 }))).toEqual({
ok: true,
id: 42,
});
expect(state.log).toHaveBeenCalled();
});
it("preserves the original mutation error if cleanup also fails", async () => {
await expect(
withCatalogExport(async () => {
throw Error("database conflict");
}),
).rejects.toThrow("database conflict");
});
it("keeps hotel retries independent from Git export scheduling", () => {
expect(isCatalogMutation("POST", "/api/admin/catalog/status")).toBe(false);
expect(isCatalogMutation("POST", "/api/admin/catalog/status/")).toBe(false);
expect(isCatalogMutation("PATCH", "/api/admin/catalog/tree")).toBe(true);
expect(isCatalogMutation("POST", "/api/admin/import/furni")).toBe(true);
});
+10 -1
View File
@@ -1,5 +1,6 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { logger } from "@/lib/logger";
import {
catalogStateRoot,
readManagedCatalogConfig,
@@ -18,6 +19,7 @@ export function catalogExportQueue() {
export function isCatalogMutation(method: string, pathname: string) {
return (
["POST", "PUT", "PATCH", "DELETE"].includes(method) &&
pathname.replace(/\/$/, "") !== "/api/admin/catalog/status" &&
(pathname.startsWith("/api/admin/import/") ||
pathname.startsWith("/api/admin/catalog/") ||
pathname.startsWith("/api/admin/furni/") ||
@@ -53,7 +55,14 @@ export async function withCatalogExport<T>(
try {
return await operation();
} finally {
await finish();
try {
await finish();
} catch (error) {
logger.error("Catalog operation finished but export scheduling failed", {
module: "catalog",
error,
});
}
}
}
+17 -16
View File
@@ -1,5 +1,6 @@
import { promises as fs } from "node:fs";
import { asc, sql } from "drizzle-orm";
import { numericValue } from "@/features/catalog/domain/offer-input";
import { CatalogPages, db } from "@/lib/db";
import { getFurnitureDataPath } from "@/lib/services/furni-data";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
@@ -147,17 +148,17 @@ export async function loadCatalogItemsData(
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
id: Number(r.id),
itemIds: String(r.item_ids ?? ""),
pageId: Number(r.page_id) || 0,
offerId: Number(r.offer_id) || -1,
songId: Number(r.song_id) || 0,
orderNumber: Number(r.order_number) || 99,
pageId: numericValue(r.page_id, 0),
offerId: numericValue(r.offer_id, -1),
songId: numericValue(r.song_id, 0),
orderNumber: numericValue(r.order_number, 99),
catalogName: String(r.catalog_name ?? ""),
costCredits: Number(r.cost_credits) || 0,
costPoints: Number(r.cost_points) || 0,
pointsType: Number(r.points_type) || 0,
amount: Number(r.amount) || 1,
limitedSells: Number(r.limited_sells) || 0,
limitedStack: Number(r.limited_stack) || 0,
costCredits: numericValue(r.cost_credits, 0),
costPoints: numericValue(r.cost_points, 0),
pointsType: numericValue(r.points_type, 0),
amount: numericValue(r.amount, 1),
limitedSells: numericValue(r.limited_sells, 0),
limitedStack: numericValue(r.limited_stack, 0),
extradata: String(r.extradata ?? ""),
haveOffer: String(r.have_offer ?? "1"),
clubOnly: String(r.club_only ?? "0"),
@@ -217,9 +218,9 @@ export async function loadCatalogItemsData(
publicName: String(r.public_name ?? ""),
itemName: String(r.item_name ?? ""),
type: String(r.type ?? "s"),
width: Number(r.width) || 1,
length: Number(r.length) || 1,
stackHeight: Number(r.stack_height) || 0,
width: numericValue(r.width, 1),
length: numericValue(r.length, 1),
stackHeight: numericValue(r.stack_height, 0),
allowStack: Number(r.allow_stack ?? 0),
allowSit: Number(r.allow_sit ?? 0),
allowLay: Number(r.allow_lay ?? 0),
@@ -230,11 +231,11 @@ export async function loadCatalogItemsData(
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
interactionType: String(r.interaction_type ?? "default"),
interactionModesCount: Number(r.interaction_modes_count) || 2,
interactionModesCount: numericValue(r.interaction_modes_count, 2),
vendingIds: String(r.vending_ids ?? "0"),
customparams: String(r.customparams ?? ""),
effectIdMale: Number(r.effect_id_male) || 0,
effectIdFemale: Number(r.effect_id_female) || 0,
effectIdMale: numericValue(r.effect_id_male, 0),
effectIdFemale: numericValue(r.effect_id_female, 0),
clothingOnWalk: String(r.clothing_on_walk ?? ""),
}));
+23 -23
View File
@@ -1,5 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const commands = vi.hoisted(() => ({
updatePageCommand: vi.fn(),
deletePageCommand: vi.fn(),
}));
vi.mock("@/features/catalog/server/page-commands", () => commands);
const insertValues = vi.hoisted(() => vi.fn());
const selectLimit = vi.hoisted(() => vi.fn());
const updateSetData = vi.hoisted(() => vi.fn());
@@ -168,22 +173,11 @@ describe("createPage", () => {
});
describe("movePage", () => {
it("throws on circular hierarchy", async () => {
selectLimit.mockResolvedValue([{ parentId: 1 }]);
await expect(movePage(1, 2)).rejects.toThrow("circular hierarchy");
});
it("allows valid move to root", async () => {
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
await movePage(1, 0);
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 0 });
});
it("allows valid move to other parent", async () => {
selectLimit.mockResolvedValue([{ parentId: 0 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
it("uses the shared structural command", async () => {
await movePage(1, 2);
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 2 });
expect(commands.updatePageCommand).toHaveBeenCalledWith("normal", 1, {
parentId: 2,
});
});
});
@@ -208,13 +202,19 @@ describe("getAncestors", () => {
});
describe("deletePage", () => {
it("reparent mode moves children to parent", async () => {
selectLimit.mockResolvedValue([{ parentId: 1 }]);
updateWhere.mockResolvedValue([{ affectedRows: 2 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
const result = await deletePage(5, "reparent");
expect(result).toEqual({ deletedPages: 1, movedChildren: 2 });
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 1 });
it("uses the shared transactional delete", async () => {
commands.deletePageCommand.mockResolvedValue({
deletedPages: 1,
movedChildren: 2,
});
expect(await deletePage(5, "reparent")).toEqual({
deletedPages: 1,
movedChildren: 2,
});
expect(commands.deletePageCommand).toHaveBeenCalledWith(
"normal",
5,
"reparent",
);
});
});
+9 -86
View File
@@ -1,4 +1,8 @@
import { asc, eq, inArray, sql } from "drizzle-orm";
import { asc, eq, sql } from "drizzle-orm";
import {
deletePageCommand,
updatePageCommand,
} from "@/features/catalog/server/page-commands";
import { CatalogPages, db } from "@/lib/db";
export type { NestedTreeNode, TreeNode } from "@/types/catalog";
@@ -72,7 +76,7 @@ export async function getTreeFlat(): Promise<TreeNode[]> {
enabled: CatalogPages.enabled,
})
.from(CatalogPages)
.orderBy(asc(CatalogPages.orderNum));
.orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id));
const itemCountMap = await getCatalogItemCounts();
const childCountMap = new Map<number, number>();
@@ -204,94 +208,13 @@ export async function movePage(
pageId: number,
newParentId: number,
): Promise<void> {
if (newParentId > 0) {
let currentId = newParentId;
for (let i = 0; i < 50; i++) {
if (currentId === pageId) {
throw new Error("Cannot move page: would create a circular hierarchy");
}
const [parent] = await db
.select({ parentId: CatalogPages.parentId })
.from(CatalogPages)
.where(eq(CatalogPages.id, currentId))
.limit(1);
if (!parent || parent.parentId <= 0) break;
currentId = parent.parentId;
}
}
await db
.update(CatalogPages)
.set({ parentId: newParentId })
.where(eq(CatalogPages.id, pageId));
await updatePageCommand("normal", pageId, { parentId: newParentId });
}
/**
* Delete catalog_items for the given page ids.
* Habbo DBs often store page_id as VARCHAR; text delete on INT or VARCHAR
* columns keeps the page_id index usable (CAST would force a full scan).
*/
async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise<void> {
if (pageIds.length === 0) return;
const idStrs = pageIds.map(String);
await db.execute(sql`
DELETE FROM catalog_items
WHERE page_id IN (${sql.join(idStrs, sql`, `)})
`);
}
/**
* Delete a page with cascade or reparent mode.
*/
export async function deletePage(
pageId: number,
mode: "cascade" | "reparent" = "reparent",
): Promise<{ deletedPages: number; movedChildren: number }> {
const [page] = await db
.select({ parentId: CatalogPages.parentId })
.from(CatalogPages)
.where(eq(CatalogPages.id, pageId))
.limit(1);
if (!page) return { deletedPages: 0, movedChildren: 0 };
if (mode === "reparent") {
const [result] = await db
.update(CatalogPages)
.set({ parentId: page.parentId })
.where(eq(CatalogPages.parentId, pageId));
await deleteCatalogItemsByPageIds([pageId]);
await db.delete(CatalogPages).where(eq(CatalogPages.id, pageId));
return { deletedPages: 1, movedChildren: result.affectedRows };
}
const deleted = await cascadeDelete(pageId);
return { deletedPages: deleted, movedChildren: 0 };
}
async function cascadeDelete(pageId: number): Promise<number> {
const toDelete: number[] = [pageId];
const queue: number[] = [pageId];
while (queue.length > 0) {
const children = await db
.select({ id: CatalogPages.id })
.from(CatalogPages)
.where(inArray(CatalogPages.parentId, queue));
queue.length = 0;
for (const child of children) {
toDelete.push(child.id);
queue.push(child.id);
}
}
await deleteCatalogItemsByPageIds(toDelete);
for (let i = toDelete.length - 1; i >= 0; i--) {
await db.delete(CatalogPages).where(eq(CatalogPages.id, toDelete[i]));
}
return toDelete.length;
) {
return deletePageCommand("normal", pageId, mode);
}
/**
+29
View File
@@ -1916,6 +1916,35 @@
"save": "Save item",
"saving": "Saving…",
"cancel": "Cancel"
},
"workspace": {
"diagnostics": "Diagnostics",
"editor": "Editor",
"normal": "Catalog",
"tree": "Tree",
"table": "Table",
"manager": "Catalog manager",
"backToCategories": "Back to categories",
"retryPage": "Retry loading page",
"retryItems": "Retry loading items",
"hotel": "Hotel",
"sent": "Update request sent",
"sendFailed": "Update request failed",
"unknown": "No status yet",
"disabled": "Not configured",
"exportFailed": "Export failed",
"queued": "Export pending",
"exported": "Export completed",
"retryHotel": "Retry hotel update",
"retryStatus": "Status unavailable — retry",
"saved": "Saved",
"globalSearch": "Search catalog",
"searchPlaceholder": "Category, offer, classname or ID",
"searchNoResults": "No results found",
"searchFailed": "Search failed. Try again.",
"searching": "Searching...",
"category": "Category",
"offer": "Offer"
}
},
"shop": {
+29
View File
@@ -1835,6 +1835,35 @@
"save": "Salva articolo",
"saving": "Salvataggio…",
"cancel": "Annulla"
},
"workspace": {
"diagnostics": "Diagnostica",
"editor": "Editor",
"normal": "Catalogo",
"tree": "Albero",
"table": "Tabella",
"manager": "Gestione catalogo",
"backToCategories": "Torna alle categorie",
"retryPage": "Riprova caricamento pagina",
"retryItems": "Riprova caricamento offerte",
"hotel": "Hotel",
"sent": "Richiesta di aggiornamento inviata",
"sendFailed": "Invio aggiornamento fallito",
"unknown": "Stato non ancora disponibile",
"disabled": "Non configurato",
"exportFailed": "Esportazione fallita",
"queued": "Esportazione in attesa",
"exported": "Esportazione completata",
"retryHotel": "Riprova aggiornamento hotel",
"retryStatus": "Stato non disponibile — riprova",
"saved": "Salvato",
"globalSearch": "Cerca nel catalogo",
"searchPlaceholder": "Categoria, offerta, classname o ID",
"searchNoResults": "Nessun risultato",
"searchFailed": "Ricerca non riuscita. Riprova.",
"searching": "Ricerca in corso...",
"category": "Categoria",
"offer": "Offerta"
}
},
"shop": {
+29
View File
@@ -1665,6 +1665,35 @@
"save": "Item opslaan",
"saving": "Opslaan…",
"cancel": "Annuleren"
},
"workspace": {
"diagnostics": "Diagnostiek",
"editor": "Editor",
"normal": "Catalogus",
"tree": "Boom",
"table": "Tabel",
"manager": "Catalogusbeheer",
"backToCategories": "Terug naar categorieën",
"retryPage": "Pagina opnieuw laden",
"retryItems": "Aanbiedingen opnieuw laden",
"hotel": "Hotel",
"sent": "Updateverzoek verzonden",
"sendFailed": "Updateverzoek mislukt",
"unknown": "Nog geen status",
"disabled": "Niet ingesteld",
"exportFailed": "Export mislukt",
"queued": "Export in wachtrij",
"exported": "Export voltooid",
"retryHotel": "Hotelupdate opnieuw proberen",
"retryStatus": "Status niet beschikbaar — opnieuw proberen",
"saved": "Opgeslagen",
"globalSearch": "Catalogus doorzoeken",
"searchPlaceholder": "Categorie, aanbieding, classname of ID",
"searchNoResults": "Geen resultaten gevonden",
"searchFailed": "Zoeken mislukt. Probeer opnieuw.",
"searching": "Zoeken...",
"category": "Categorie",
"offer": "Aanbieding"
}
},
"shop": {