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; 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( 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 { 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 }; }, ); }