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:
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;
|
||||
|
||||
@@ -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
Reference in new issue
Block a user