Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 28s
CI / tests-unit (push) Successful in 1m39s
CI / tests-integration (push) Successful in 1m42s
CI / tests-ui (push) Successful in 2m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m6s
The previous commit taught bulk editing and delete-with-restore about the BC catalog. Neither actually worked, and one of them was destructive. `catalog_items_bc` has six columns: id, item_ids, page_id, catalog_name, order_number, extradata. There is no price, points, currency, offer_id, limit or membership column on it. The bulk path read and wrote columns that do not exist, and the UPDATE was aimed at catalog_items while the SELECT came from catalog_items_bc — so a BC category move wrote into the normal catalog. Two tests now pin that pairing: reads and writes have to stay in the same table. Underneath it the BC table was never being read at all. The inline editor fetched `/api/admin/catalog/items?pageId=N` without the catalog, so opening a BC category showed the normal catalog's offers, and the route selected BC rows directly instead of going through the loader, skipping the furni enrichment the table needs to render anything but a bare caption. Both catalogs now take the same path, and the catalog is in the fetch callback's dependencies — without that, a switch keeps reading the previous catalog's rows through a stale closure. Because a BC offer has no price, the editor no longer offers one. The server refuses price, points and currency changes with a readable message instead of letting them reach the database as an unknown-column error, and a BC bulk edit is what it can actually be: a category move. BC deletions also went through a bare DELETE, which made them the one catalog mutation with no way back. They now keep their rows and hand back a restoreId like the normal ones. The catalog is recorded in the audit target rather than in the payload, so a restore can never put a BC row into the normal offers table.
287 lines
7.5 KiB
TypeScript
287 lines
7.5 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { catalogFailure } from "@/features/catalog/server/errors";
|
|
import { deleteCatalogItemsCommand } from "@/features/catalog/server/item-deletes";
|
|
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 { PERMS } from "@/lib/permissions";
|
|
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
|
|
const BC_PAGE_FIELDS = [
|
|
"caption",
|
|
"parentId",
|
|
"pageLayout",
|
|
"enabled",
|
|
"visible",
|
|
"orderNum",
|
|
"iconImage",
|
|
"iconColor",
|
|
"pageHeadline",
|
|
"pageTeaser",
|
|
"pageSpecial",
|
|
"pageText1",
|
|
"pageText2",
|
|
"pageTextDetails",
|
|
"pageTextTeaser",
|
|
] as const;
|
|
|
|
const BC_ITEM_FIELDS = [
|
|
"itemIds",
|
|
"catalogName",
|
|
"orderNumber",
|
|
"extradata",
|
|
"pageId",
|
|
] as const;
|
|
|
|
function pickAllowed(
|
|
fields: Record<string, unknown>,
|
|
allowed: readonly string[],
|
|
) {
|
|
const out: Record<string, unknown> = {};
|
|
for (const key of allowed) {
|
|
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
|
out[key] = fields[key];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function updateBcPage({
|
|
id,
|
|
expected,
|
|
...fields
|
|
}: { 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" };
|
|
}
|
|
try {
|
|
await updatePageCommand("bc", id, data, expected, staff.id);
|
|
} catch (error) {
|
|
return { ok: false as const, error: catalogFailure(error).message };
|
|
}
|
|
await sendCatalogUpdate();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "bc_page_update",
|
|
description: `Updated BC catalog page #${id}`,
|
|
targetType: "catalog_page_bc",
|
|
targetId: id,
|
|
});
|
|
revalidatePath("/admin/catalog/builder-club");
|
|
return { ok: true as const };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* BC offers are deleted through the same keep-and-restore path as normal ones.
|
|
* It used to be a bare `DELETE` here, so a BC deletion was the one catalog
|
|
* mutation with no way back.
|
|
*/
|
|
export async function deleteBcItem({
|
|
id,
|
|
requestKey,
|
|
}: {
|
|
id: number;
|
|
requestKey?: string;
|
|
}) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
try {
|
|
return await withCatalogExport(async () => {
|
|
const data: { deleted: number; restoreId: number } =
|
|
await deleteCatalogItemsCommand([id], staff.id, requestKey, "bc");
|
|
await sendCatalogUpdate();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "bc_item_delete",
|
|
description: `Deleted BC catalog offer #${id}`,
|
|
targetType: "catalog_item_bc",
|
|
targetId: id,
|
|
});
|
|
revalidatePath("/admin/catalog/builder-club");
|
|
return { ok: true as const, data };
|
|
});
|
|
} catch (error) {
|
|
return { ok: false as const, error: catalogFailure(error).message };
|
|
}
|
|
}
|
|
|
|
export async function updateBcItem({
|
|
id,
|
|
...data
|
|
}: {
|
|
id: number;
|
|
itemIds?: string;
|
|
catalogName?: string;
|
|
orderNumber?: number;
|
|
extradata?: string;
|
|
}) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
const safe = pickAllowed(data as Record<string, unknown>, BC_ITEM_FIELDS);
|
|
if (Object.keys(safe).length === 0) {
|
|
return { ok: false as const, error: "No valid fields to update" };
|
|
}
|
|
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",
|
|
description: `Updated BC catalog item #${id}`,
|
|
targetType: "catalog_item_bc",
|
|
targetId: id,
|
|
});
|
|
revalidatePath("/admin/catalog/builder-club");
|
|
return { ok: true as const };
|
|
});
|
|
}
|
|
|
|
export async function createBcItem({
|
|
pageId,
|
|
...data
|
|
}: {
|
|
pageId: number;
|
|
itemIds: string;
|
|
catalogName: string;
|
|
orderNumber: number;
|
|
extradata: string;
|
|
}) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
const createdId = await createBcOfferCommand({ pageId, ...data });
|
|
await sendCatalogUpdate();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "bc_item_create",
|
|
description: `Created BC catalog item #${createdId}`,
|
|
targetType: "catalog_item_bc",
|
|
targetId: createdId,
|
|
});
|
|
revalidatePath("/admin/catalog/builder-club");
|
|
return { ok: true as const, data: { id: createdId } };
|
|
});
|
|
}
|
|
|
|
export async function toggleBcPage({
|
|
id,
|
|
field,
|
|
}: {
|
|
id: number;
|
|
field: "enabled" | "visible";
|
|
}) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
await togglePageCommand("bc", id, field, staff.id);
|
|
await sendCatalogUpdate();
|
|
revalidatePath("/admin/catalog/builder-club");
|
|
return { ok: true as const };
|
|
});
|
|
}
|
|
|
|
export async function createBcPage(input: {
|
|
caption: string;
|
|
parentId: number;
|
|
pageLayout?: string;
|
|
iconImage?: number;
|
|
iconColor?: number;
|
|
enabled?: "0" | "1";
|
|
visible?: "0" | "1";
|
|
orderNum?: number;
|
|
}) {
|
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
const createdId = await createPageCommand("bc", {
|
|
caption: input.caption,
|
|
parentId: input.parentId,
|
|
pageLayout: input.pageLayout ?? "default_3x3",
|
|
iconColor: input.iconColor ?? 0,
|
|
iconImage: input.iconImage ?? 0,
|
|
orderNum: input.orderNum ?? 0,
|
|
visible: input.visible ?? "1",
|
|
enabled: input.enabled ?? "1",
|
|
pageHeadline: "",
|
|
pageTeaser: "",
|
|
});
|
|
await sendCatalogUpdate();
|
|
await logStaffActivity({
|
|
staffId: staff.id,
|
|
action: "bc_page_create",
|
|
description: `Created BC catalog page "${input.caption}"`,
|
|
targetType: "catalog_page_bc",
|
|
targetId: createdId,
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
revalidatePath("/admin/catalog/builder-club");
|
|
return { ok: true as const, data: { id: createdId } };
|
|
});
|
|
}
|
|
|
|
export async function reorderBcTreePage(input: {
|
|
pageId: number;
|
|
newParentId?: number;
|
|
newOrderNum: number;
|
|
}) {
|
|
await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
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: {} };
|
|
});
|
|
}
|
|
|
|
export async function deleteBcTreePage(input: {
|
|
pageId: number;
|
|
mode: "reparent" | "cascade";
|
|
}) {
|
|
await requirePermission(PERMS.CATALOG_EDIT);
|
|
return await withCatalogExport(async () => {
|
|
await deletePageCommand("bc", input.pageId, input.mode);
|
|
await sendCatalogUpdate();
|
|
revalidatePath("/admin/catalog");
|
|
revalidatePath("/admin/catalog/builder-club");
|
|
return { ok: true as const, data: {} };
|
|
});
|
|
}
|
|
|
|
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: {} };
|
|
});
|
|
}
|