Files
Epicnabbo-Catalogus-Updated…/src/features/catalog/server/item-deletes.ts
T
openhands e0efbef30d 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.
2026-09-30 19:17:20 +02:00

335 lines
10 KiB
TypeScript

import "server-only";
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { enqueueEffect, runOperation } from "@/features/operations/server";
import { AdminAuditLog, db, rowsFrom } from "@/lib/db";
import { getOperationContext } from "@/lib/foundation/request-context";
import { CatalogInputError, CatalogNotFound } from "../domain/hierarchy";
/**
* A deleted offer cannot be restored from a field snapshot: the undo needs the
* row itself, because a delete takes every column with it and a later create may
* have reused the id. So the full row is serialised into the audit log at delete
* time and the restore is a re-insert of that exact row.
*/
export type ItemCatalog = "normal" | "bc";
/**
* The BC table stores only a reference to the furniture — no price, limit or
* membership columns exist there. Reading or writing them would be an unknown
* column error, so the two catalogs get their own column list rather than one
* that silently drops fields.
*/
const NORMAL_COLUMNS = [
"id",
"itemIds",
"pageId",
"catalogName",
"costCredits",
"costPoints",
"pointsType",
"amount",
"limitedStack",
"limitedSells",
"orderNumber",
"offerId",
"songId",
"extradata",
"haveOffer",
"clubOnly",
] as const;
const BC_COLUMNS = [
"id",
"itemIds",
"pageId",
"catalogName",
"orderNumber",
"extradata",
] as const;
function columnsFor(catalog: ItemCatalog): readonly string[] {
return catalog === "bc" ? BC_COLUMNS : NORMAL_COLUMNS;
}
function offersTable(catalog: ItemCatalog) {
return catalog === "bc" ? "catalog_items_bc" : "catalog_items";
}
function pagesTable(catalog: ItemCatalog) {
return catalog === "bc" ? "catalog_pages_bc" : "catalog_pages";
}
type DeletedOffer = Record<string, string | number>;
/**
* `haveOffer` is camelCase in the database itself; every other column is
* snake_case, so the mapping is written out once rather than derived per call.
*/
const COLUMN_SQL_NAMES: Record<(typeof NORMAL_COLUMNS)[number], string> = {
id: "id",
itemIds: "item_ids",
pageId: "page_id",
catalogName: "catalog_name",
costCredits: "cost_credits",
costPoints: "cost_points",
pointsType: "points_type",
amount: "amount",
limitedStack: "limited_stack",
limitedSells: "limited_sells",
orderNumber: "order_number",
offerId: "offer_id",
songId: "song_id",
extradata: "extradata",
haveOffer: "haveOffer",
clubOnly: "club_only",
};
function restoreIds(catalog: ItemCatalog) {
return sql.join(
columnsFor(catalog).map((key) =>
sql.identifier(COLUMN_SQL_NAMES[key as keyof typeof COLUMN_SQL_NAMES]),
),
sql`, `,
);
}
function deletedOfferSelect(catalog: ItemCatalog) {
return sql.join(
columnsFor(catalog).map(
(key) =>
sql`${sql.identifier(COLUMN_SQL_NAMES[key as keyof typeof COLUMN_SQL_NAMES])} AS ${sql.identifier(key)}`,
),
sql`, `,
);
}
/**
* The catalog is encoded in the action and target columns rather than inferred
* from the payload, so a restore can never re-insert a BC row into the normal
* offers table.
*/
function deleteAction(catalog: ItemCatalog) {
return `${offersTable(catalog)}_delete_restore`;
}
function catalogFromTarget(target: string): ItemCatalog | null {
if (target === "catalog_items_bc") return "bc";
if (target === "catalog_items") return "normal";
return null;
}
function parseOffers(raw: string | null, catalog: ItemCatalog): DeletedOffer[] {
let value: unknown;
try {
value = JSON.parse(raw ?? "null");
} catch {
throw Error("unavailable");
}
if (!Array.isArray(value) || !value.length) throw Error("unavailable");
for (const row of value) {
if (!row || typeof row !== "object" || Array.isArray(row)) {
throw Error("unavailable");
}
const record = row as Record<string, unknown>;
if (!columnsFor(catalog).every((key) => key in record)) {
throw Error("unavailable");
}
}
return value as DeletedOffer[];
}
/**
* Delete offers, keeping enough of each row that the delete can be undone.
* Returns the audit id the client can restore from.
*/
export async function deleteCatalogItemsCommand(
ids: number[],
userId: number,
requestKey: string = randomUUID(),
catalog: ItemCatalog = "normal",
) {
const selection = [...new Set(ids)].sort((a, b) => a - b);
if (
!selection.length ||
selection.length > 500 ||
selection.some((id) => !Number.isSafeInteger(id) || id < 1)
)
throw new CatalogInputError("Invalid selection");
return runOperation(
{
actorId: userId,
kind: "catalog.items.delete",
key: requestKey,
// The catalog is part of the request identity: one delete key replayed
// against the other catalog is not the same work.
input: { ids: selection, catalog },
},
async (tx, operationId) => {
const removed = rowsFrom<DeletedOffer>(
await tx.execute(
sql`SELECT ${deletedOfferSelect(catalog)} FROM ${sql.raw(offersTable(catalog))} WHERE id IN (${sql.join(
selection,
sql`, `,
)}) ORDER BY id FOR UPDATE`,
),
);
// A row that vanished under us means someone else already deleted it;
// restoring the rest anyway would leave the caller believing the whole
// selection succeeded.
if (removed.length !== selection.length)
throw new CatalogNotFound(
"Some selected offers were already deleted. Reload the selection.",
);
await tx.execute(
sql`DELETE FROM ${sql.raw(offersTable(catalog))} WHERE id IN (${sql.join(selection, sql`, `)})`,
);
const inserted = await tx.insert(AdminAuditLog).values({
userId,
action: deleteAction(catalog),
target: offersTable(catalog),
targetId: null,
details: JSON.stringify(getOperationContext()),
before: JSON.stringify(removed),
after: null,
createdAt: new Date().toISOString(),
});
const restoreId = inserted?.[0]?.insertId;
if (!restoreId) throw Error("Delete could not be recorded for restore");
await enqueueEffect(tx, operationId, "catalog.refresh");
await enqueueEffect(tx, operationId, "catalog.export.request");
return { deleted: removed.length, restoreId: Number(restoreId) };
},
);
}
/**
* Put a deleted selection back. Refuses if any id has since been taken by
* another offer, so a restore can never overwrite unrelated work.
*/
/**
* Recent deletions that can still be taken back, newest first. The undo toast
* is a short window; this is what makes the restore record reachable after it,
* so a delete noticed ten minutes later is not a hand-editing job.
*/
export interface RestorableDeletion {
restoreId: number;
catalog: ItemCatalog;
offers: number;
deletedAt: string;
staffId: number;
}
export async function listRestorableDeletionsCommand(
limit = 20,
): Promise<RestorableDeletion[]> {
const capped = Math.min(Math.max(Math.trunc(limit) || 20, 1), 100);
const rows = rowsFrom<{
id: number;
userId: number;
target: string;
before: string | null;
createdAt: string;
}>(
await db.execute(
sql`SELECT id, user_id AS userId, target, before, created_at AS createdAt FROM ${AdminAuditLog} WHERE action IN (${deleteAction("normal")}, ${deleteAction("bc")}) ORDER BY id DESC LIMIT ${capped}`,
),
);
const entries: RestorableDeletion[] = [];
for (const row of rows) {
const catalog = catalogFromTarget(String(row.target));
if (catalog === null) continue;
try {
entries.push({
restoreId: Number(row.id),
catalog,
offers: parseOffers(row.before, catalog).length,
deletedAt: String(row.createdAt ?? ""),
staffId: Number(row.userId),
});
} catch {
// One corrupt record must not fail the whole list.
}
}
return entries;
}
export async function restoreDeletedCatalogItemsCommand(
restoreId: number,
userId: number,
requestKey: string = randomUUID(),
) {
if (!Number.isSafeInteger(restoreId) || restoreId < 1)
throw new CatalogInputError("Invalid restore selection");
return runOperation(
{
actorId: userId,
kind: "catalog.items.restore",
key: requestKey,
input: { restoreId },
},
// The lock above is what serialises two restores of the same deletion:
// the audit row is read FOR UPDATE, so the second one sees it already
// gone and stops rather than re-inserting.
async (tx, operationId) => {
const [entry] = await tx
.select()
.from(AdminAuditLog)
.where(sql`${AdminAuditLog.id}=${restoreId} FOR UPDATE`)
.limit(1);
const catalog = entry ? catalogFromTarget(entry.target) : null;
if (!entry || catalog === null || entry.action !== deleteAction(catalog))
throw new CatalogNotFound("This deletion can no longer be restored");
const offers = parseOffers(entry.before, catalog);
const ids = offers.map((offer) => Number(offer.id));
const taken = rowsFrom<{ id: number }>(
await tx.execute(
sql`SELECT id FROM ${sql.raw(offersTable(catalog))} WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id FOR UPDATE`,
),
);
if (taken.length)
throw new CatalogNotFound(
"Other offers now use these ids. Restore was not applied.",
);
// A restored offer pointing at a deleted category would sell nothing and
// would not show up under any page, so the restore has to notice that
// first rather than write an unreachable row.
const pageIds = [
...new Set(
offers.map((offer) => Number(offer.pageId)).filter((id) => id > 0),
),
].sort((a, b) => a - b);
if (pageIds.length) {
const found = new Set(
rowsFrom<{ id: number }>(
await tx.execute(
sql`SELECT id FROM ${sql.raw(pagesTable(catalog))} WHERE id IN (${sql.join(pageIds, sql`, `)}) ORDER BY id FOR UPDATE`,
),
).map((row) => Number(row.id)),
);
const missing = pageIds.filter((id) => !found.has(id));
if (missing.length)
throw new CatalogNotFound(
`These categories no longer exist, so the offers would be unreachable: ${missing.join(", ")}. Restore was not applied.`,
);
}
const keys = columnsFor(catalog);
for (const offer of offers) {
await tx.execute(
sql`INSERT INTO ${sql.raw(offersTable(catalog))} (${restoreIds(catalog)}) VALUES (${sql.join(
keys.map((key) => sql`${offer[key]}`),
sql`, `,
)})`,
);
}
// The audit entry has served its purpose once the rows are back.
await tx
.delete(AdminAuditLog)
.where(sql`${AdminAuditLog.id}=${restoreId}`);
await enqueueEffect(tx, operationId, "catalog.refresh");
await enqueueEffect(tx, operationId, "catalog.export.request");
return { restored: offers.length };
},
);
}