refactor(catalog): unify commands and embed guarded catalog workspace
This commit is contained in:
1 parent
16f35568f5
commit
193b6686a9
57 files changed
+3268
-728
No files matched your search
+60
-118
@@ -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
@@ -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
@@ -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 },
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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() }),
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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=_=%==%");
|
||||
});
|
||||
});
|
||||
@@ -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}`)}%`,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? ""),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user