fix(catalog): make the Builder Club catalog read and write its own offers

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

File diff suppressed because it is too large. Load diff
@@ -196,6 +196,51 @@ it("locks pages before offers and commits the entire batch", async () => {
.every((q) => !q.includes("cost_points")),
).toBe(true);
});
it("refuses a price change on a BC offer, which has no price column", async () => {
await expect(
previewBulkOffersCommand(
{
ids: [1, 2],
changes: { costCredits: { mode: "set", value: 5 } },
},
"bc",
),
).rejects.toThrow(/Builder Club offer has no price/);
expect(state.writes).toBe(0);
});
it("refuses a currency change on a BC offer too", async () => {
await expect(
previewBulkOffersCommand({ ids: [1], changes: { pointsType: 5 } }, "bc"),
).rejects.toThrow(/currency/);
});
it("allows a BC category move and writes no column BC lacks", async () => {
const p = await previewBulkOffersCommand(
{ ids: [1, 2], changes: { pageId: 9 } },
"bc",
);
expect(p.changedCount).toBe(2);
state.queries = [];
await applyBulkOffersCommand(
{ ids: [1, 2], changes: { pageId: 9 } },
p.fingerprint,
undefined,
undefined,
"bc",
);
// The read and the write both have to stay inside the BC table: reading from
// BC and writing to normal is how a category move would silently corrupt the
// wrong catalog.
expect(state.queries.some((q) => q.includes("catalog_items_bc"))).toBe(true);
expect(
state.queries.some(
(q) => q.startsWith("UPDATE") && !q.includes("catalog_items_bc"),
),
).toBe(false);
expect(state.audit).toHaveLength(0);
});
it("refuses stale prices and changed intent before writing", async () => {
const p = await previewBulkOffersCommand(input);
state.rows[0].costCredits = 100;
+38 -3
View File
@@ -25,6 +25,29 @@ import {
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
export type BulkCatalogKind = "normal" | "bc";
/**
* `catalog_items_bc` stores only a reference to the furniture: there are no
* cost, points, currency or offer_id columns on it at all. So BC bulk editing is
* a category move and nothing else — the other fields are refused here rather
* than reaching the database as an unknown-column error.
*/
const BC_FIELD_LABELS: Record<string, string> = {
costCredits: "price",
costPoints: "points",
pointsType: "currency",
};
function assertEditableFields(input: BulkOfferInput, kind: BulkCatalogKind) {
if (kind !== "bc") return;
const refused = Object.keys(input.changes).filter(
(key) => BC_FIELD_LABELS[key] !== undefined,
);
if (refused.length)
throw new CatalogInputError(
`A Builder Club offer has no ${refused.map((key) => BC_FIELD_LABELS[key]).join(" or ")}. It can only be moved to another category.`,
);
}
/**
* The BC catalog has the same offer columns under a second table, so bulk
* editing is a table choice rather than a second code path. Only normal offers
@@ -46,7 +69,11 @@ async function readOffers(
): Promise<OfferRow[]> {
const offers = rowsFrom<OfferRow>(
await tx.execute(
sql`SELECT id, catalog_name AS catalogName, page_id AS pageId, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType FROM ${offerTable(kind)} WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
sql`SELECT id, catalog_name AS catalogName, page_id AS pageId ${
kind === "bc"
? sql`, 0 AS costCredits, 0 AS costPoints, 0 AS pointsType`
: sql`, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType`
} FROM ${offerTable(kind)} WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
),
).map((row) => ({
...row,
@@ -126,6 +153,7 @@ export async function previewBulkOffersCommand(
kind: BulkCatalogKind = "normal",
): Promise<BulkOfferPreview> {
const input = bulkOfferInputSchema.parse(value);
assertEditableFields(input, kind);
return db.transaction(async (tx) => {
const offers = await readOffers(tx, input.ids, kind);
return preview(input, offers, await readPages(tx, offers, input, kind));
@@ -139,6 +167,7 @@ export async function applyBulkOffersCommand(
kind: BulkCatalogKind = "normal",
) {
const input = bulkOfferInputSchema.parse(value);
assertEditableFields(input, kind);
if (!/^[a-f0-9]{64}$/.test(fingerprint))
throw new CatalogInputError("A valid preview is required");
const work = async (tx: Tx, operationId?: string) => {
@@ -163,10 +192,16 @@ export async function applyBulkOffersCommand(
for (const row of result.rows) {
const changes = (
Object.keys(input.changes) as Array<keyof BulkOfferValues>
).filter((key) => row.before[key] !== row.after[key]);
).filter(
(key) =>
// `assertEditableFields` already refused these, so a BC update
// only ever writes page_id.
(kind !== "bc" || key === "pageId") &&
row.before[key] !== row.after[key],
);
if (!changes.length) continue;
await tx.execute(
sql`UPDATE catalog_items SET ${sql.join(
sql`UPDATE ${offerTable(kind)} SET ${sql.join(
changes.map(
(key) =>
sql`${sql.identifier(columns[key])}=${key === "pageId" ? String(row.after[key]) : row.after[key]}`,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff