Files
Epicnabbo-Catalogus-Updated…/src/features/catalog/server/item-deletes.ts
T
openhands cebcf440c5 feat(catalog): record bulk edits, make deletions reversible, unify the tree read
A bulk offer edit is the catalog mutation that rewrites hundreds of rows at
once, and it was the only one writing nothing to the staff activity log: 22 of
the 43 catalog actions logged, this one did not. The entry it now writes says
what changed, not just that something did, because the log has no undo of its
own and "bulk updated 200 offers" cannot answer the question it exists for.

Deleting offers had no inverse at all. Every removed row is now kept at delete
time and the caller gets a restoreId back, so an accidental multi-select is a
click rather than a hand-edit of the table. The undo toast covers the common
case; a RecentDeletionsPanel holds the same records so a delete noticed later is
still reachable. Three refusals guard it: an id that another offer has since
taken, a category that no longer exists (which would leave an offer that sells
nowhere and shows under no page), and a delete whose restore record cannot be
written — that one rolls back rather than deleting without a way back. Reading
the audit row FOR UPDATE is also what stops two restores of one deletion from
both inserting.

sendCatalogUpdate() overwrote hotel-status.json on every write, so "which
imports reached the hotel" was answerable for the last attempt only, and a
failure two imports ago was gone by the time anyone looked. That file is now
also appended to as a bounded 50-entry tail.

The tree route carried four copies of the same page-select-plus-counts
shaping, of which the BC branches had already drifted: one counted offers
through the VARCHAR-tolerant helper, the other inline and swallowing errors.
All of it is one readPages() now, and readFullTree sends both catalogs through
one depth computation instead of delegating normal to getTreeFlat while
computing BC here — a split that left two implementations behind one function
name. getTreeFlat is gone. The BC ancestor walk also went from 20 levels to 50,
matching getAncestors, so a deeply nested catalog no longer loses its
breadcrumb.

Bulk editing reaches the BC catalog, which previously had no way to edit or
duplicate offers in bulk. The catalog is part of the operation identity now, so
replaying one request key against the other catalog is not mistaken for the
same work.

Integration tests failed to import: the next/cache mock supplied only
revalidatePath, and catalog-totals calls unstable_cache at module scope.
2026-09-30 18:19:36 +02:00

279 lines
8.3 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.
*/
const RESTORE_COLUMNS = [
"id",
"itemIds",
"pageId",
"catalogName",
"costCredits",
"costPoints",
"pointsType",
"amount",
"limitedStack",
"limitedSells",
"orderNumber",
"offerId",
"songId",
"extradata",
"haveOffer",
"clubOnly",
] as const;
type DeletedOffer = Record<(typeof RESTORE_COLUMNS)[number], 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 RESTORE_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",
};
const RESTORE_IDS = sql.join(
RESTORE_COLUMNS.map((key) => sql.identifier(COLUMN_SQL_NAMES[key])),
sql`, `,
);
function deletedOfferSelect() {
return sql.join(
RESTORE_COLUMNS.map(
(key) =>
sql`${sql.identifier(COLUMN_SQL_NAMES[key])} AS ${sql.identifier(key)}`,
),
sql`, `,
);
}
const DELETE_ACTION = "catalog_items_delete_restore";
function parseOffers(raw: string | null): 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 (!RESTORE_COLUMNS.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(),
) {
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,
input: { ids: selection },
},
async (tx, operationId) => {
const removed = rowsFrom<DeletedOffer>(
await tx.execute(
sql`SELECT ${deletedOfferSelect()} FROM catalog_items 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 catalog_items WHERE id IN (${sql.join(selection, sql`, `)})`,
);
const inserted = await tx.insert(AdminAuditLog).values({
userId,
action: DELETE_ACTION,
target: "catalog_items",
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;
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;
before: string | null;
createdAt: string;
}>(
await db.execute(
sql`SELECT id, user_id AS userId, before, created_at AS createdAt FROM ${AdminAuditLog} WHERE action=${DELETE_ACTION} ORDER BY id DESC LIMIT ${capped}`,
),
);
const entries: RestorableDeletion[] = [];
for (const row of rows) {
try {
entries.push({
restoreId: Number(row.id),
offers: parseOffers(row.before).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);
if (!entry || entry.action !== DELETE_ACTION)
throw new CatalogNotFound("This deletion can no longer be restored");
const offers = parseOffers(entry.before);
const ids = offers.map((offer) => Number(offer.id));
const taken = rowsFrom<{ id: number }>(
await tx.execute(
sql`SELECT id FROM catalog_items 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 catalog_pages 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.`,
);
}
for (const offer of offers) {
await tx.execute(
sql`INSERT INTO catalog_items (${RESTORE_IDS}) VALUES (${sql.join(
RESTORE_COLUMNS.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 };
},
);
}