fix(catalog): make the Builder Club catalog read and write its own offers
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.
This commit is contained in:
openhands committed 2026-09-30 19:17:20 +02:00
1 parent cebcf440c5
commit e0efbef30d
12 files changed
+548 -218

No files matched your search

+30 -11
View File
@@ -1,5 +1,4 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
@@ -50,6 +49,13 @@ vi.mock("@/features/catalog/server/page-commands", () => ({
togglePageCommand: mockTogglePageCommand,
}));
const mockDeleteCatalogItemsCommand = vi.hoisted(() =>
vi.fn(async () => ({ deleted: 1, restoreId: 55 })),
);
vi.mock("@/features/catalog/server/item-deletes", () => ({
deleteCatalogItemsCommand: mockDeleteCatalogItemsCommand,
}));
const mockSendCatalogUpdate = vi.hoisted(() => vi.fn());
vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: mockSendCatalogUpdate,
@@ -86,7 +92,6 @@ vi.mock("@/lib/db", async () => {
};
});
import { CatalogItemsBc } from "@/lib/db";
import {
createBcItem,
createBcPage,
@@ -179,16 +184,25 @@ describe("updateBcPage", () => {
});
describe("deleteBcItem", () => {
it("deletes the bc item and logs activity", async () => {
it("deletes through the restorable path and logs activity", async () => {
const result = await deleteBcItem({ id: 7 });
expect(result).toEqual({ ok: true });
expect(state.deletes).toHaveLength(1);
expect(state.deletes[0].table).toBe(CatalogItemsBc);
expect(state.deletes[0].where).toEqual(eq(CatalogItemsBc.id, 7));
expect(result).toEqual({
ok: true,
data: { deleted: 1, restoreId: 55 },
});
// Not a raw DELETE: BC rows are kept so the delete can be undone, the same
// as the normal catalog.
expect(mockDeleteCatalogItemsCommand).toHaveBeenCalledWith(
[7],
expect.anything(),
undefined,
"bc",
);
expect(state.deletes).toHaveLength(0);
expect(mockLogStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: "bc_item_delete",
description: "Deleted BC catalog item #7",
description: "Deleted BC catalog offer #7",
}),
);
expect(mockSendCatalogUpdate).toHaveBeenCalled();
@@ -197,9 +211,14 @@ describe("deleteBcItem", () => {
);
});
it("propagates delete failures", async () => {
state.deleteError = new Error("db down");
await expect(deleteBcItem({ id: 7 })).rejects.toThrow("db down");
it("reports a failed delete instead of pretending it worked", async () => {
mockDeleteCatalogItemsCommand.mockRejectedValueOnce(Error("db down"));
mockCatalogFailure.mockReturnValueOnce({ message: "Delete failed" });
const result = await deleteBcItem({ id: 7 });
expect(result).toEqual({ ok: false, error: "Delete failed" });
expect(mockSendCatalogUpdate).not.toHaveBeenCalled();
});
});
+30 -15
View File
@@ -1,8 +1,8 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { catalogFailure } from "@/features/catalog/server/errors";
import { deleteCatalogItemsCommand } from "@/features/catalog/server/item-deletes";
import {
createBcOfferCommand,
updateBcOfferCommand,
@@ -16,7 +16,6 @@ import {
} from "@/features/catalog/server/page-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogItemsBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -92,21 +91,37 @@ export async function updateBcPage({
});
}
export async function deleteBcItem({ id }: { id: number }) {
/**
* 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);
return await withCatalogExport(async () => {
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_delete",
description: `Deleted BC catalog item #${id}`,
targetType: "catalog_item_bc",
targetId: id,
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 };
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
});
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
export async function updateBcItem({
+11 -3
View File
@@ -210,9 +210,11 @@ export async function bulkCreateCatalogItems({
export async function deleteCatalogItems({
ids,
requestKey,
catalog = "normal",
}: {
ids: number[];
requestKey?: string;
catalog?: "normal" | "bc";
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
try {
@@ -220,15 +222,21 @@ export async function deleteCatalogItems({
const data: {
deleted: number;
restoreId: number;
} = await deleteCatalogItemsCommand(ids, staff.id, requestKey);
} = await deleteCatalogItemsCommand(
ids,
staff.id,
requestKey,
catalog === "bc" ? "bc" : "normal",
);
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_delete",
description: `Deleted ${data.deleted} catalog offer(s): ${ids.join(", ")}`,
targetType: "catalog_item",
description: `Deleted ${data.deleted} ${catalog === "bc" ? "BC " : ""}catalog offer(s): ${ids.join(", ")}`,
targetType: catalog === "bc" ? "catalog_item_bc" : "catalog_item",
});
revalidatePath("/admin/catalog");
if (catalog === "bc") revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data };
});
} catch (error) {