Files
EpicNext-Cms/src/actions/catalog-bulk.ts
T
openhands cebcf440c5
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m43s
CI / tests-unit (push) Successful in 1m48s
CI / tests-ui (push) Successful in 2m37s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m23s
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

179 lines
4.9 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import type { BulkOfferInput } from "@/features/catalog/domain/bulk-offers";
import { CatalogInputError } from "@/features/catalog/domain/hierarchy";
import {
applyBulkOffersCommand,
type BulkCatalogKind,
listBulkOfferDestinationsCommand,
previewBulkOffersCommand,
undoBulkOffersCommand,
} from "@/features/catalog/server/bulk-offers";
import { catalogFailure } from "@/features/catalog/server/errors";
import { OperationConflict } from "@/features/operations/model";
import { requirePermission } from "@/lib/admin/guard";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** The catalog is a server boundary, not a client choice. */
function bulkKind(catalog: unknown): BulkCatalogKind {
if (catalog === "bc") return "bc";
if (catalog === "normal" || catalog === undefined) return "normal";
throw new CatalogInputError("Invalid catalog");
}
function catalogLabel(kind: BulkCatalogKind): string {
return kind === "bc" ? "Builder Club" : "catalog";
}
const FIELD_LABELS: Record<keyof BulkOfferInput["changes"], string> = {
costCredits: "credits",
costPoints: "points",
pointsType: "points type",
pageId: "category",
};
/**
* The audit log has no undo of its own, so it must record *what* changed, not
* just that something did. An entry that only says "bulk updated 200 offers"
* cannot answer the question the log exists for.
*/
function describeChanges(changes: BulkOfferInput["changes"]): string {
return Object.entries(changes)
.map(([key, change]) => {
if (change && typeof change === "object" && "mode" in change)
return `${FIELD_LABELS[key as keyof BulkOfferInput["changes"]]} ${
change.mode === "percent"
? `by ${change.value}%`
: `${change.mode} ${change.value}`
}`;
if (key === "pageId") return `category moved to #${String(change)}`;
return `${FIELD_LABELS[key as keyof BulkOfferInput["changes"]]} = ${String(change)}`;
})
.join(", ");
}
export async function previewBulkOffers(
input: BulkOfferInput,
catalog: unknown = "normal",
) {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return {
ok: true as const,
data: await previewBulkOffersCommand(input, bulkKind(catalog)),
};
} catch (error) {
return {
ok: false as const,
error:
error instanceof OperationConflict
? error.message
: catalogFailure(error).message,
};
}
}
export async function applyBulkOffers(
input: BulkOfferInput,
fingerprint: string,
requestKey?: string,
catalog: unknown = "normal",
) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const kind = bulkKind(catalog);
try {
return await withCatalogExport(async () => {
const data = await applyBulkOffersCommand(
input,
fingerprint,
staff.id,
requestKey,
kind,
);
if (data.changedCount > 0) {
await logStaffActivity({
staffId: staff.id,
action: "catalog_bulk_offers",
description: `Bulk updated ${data.changedCount} ${catalogLabel(kind)} offer(s) across ${new Set(input.ids).size} selected: ${describeChanges(input.changes)}`,
targetType: "catalog_page",
targetId: input.changes.pageId,
});
refreshCatalog();
}
return { ok: true as const, data };
});
} catch (error) {
return {
ok: false as const,
error:
error instanceof OperationConflict
? error.message
: catalogFailure(error).message,
};
}
}
export async function getBulkOfferDestinations(catalog: unknown = "normal") {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return {
ok: true as const,
data: await listBulkOfferDestinationsCommand(bulkKind(catalog)),
};
} catch (error) {
return {
ok: false as const,
error:
error instanceof OperationConflict
? error.message
: catalogFailure(error).message,
};
}
}
export async function undoBulkOffers(
historyIds: number[],
requestKey?: string,
) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
try {
return await withCatalogExport(async () => {
const data = await undoBulkOffersCommand(
historyIds,
staff.id,
requestKey,
);
await logStaffActivity({
staffId: staff.id,
action: "catalog_bulk_undo",
description: `Restored ${data.changedCount} catalog offer(s) from history #${historyIds.join(", ")}`,
targetType: "catalog_offer",
});
refreshCatalog();
return { ok: true as const, data };
});
} catch (error) {
return {
ok: false as const,
error:
error instanceof OperationConflict
? error.message
: catalogFailure(error).message,
};
}
}
function refreshCatalog() {
try {
revalidatePath("/admin/catalog", "layout");
revalidatePath("/admin/logs/audit");
} catch (error) {
logger.error("Catalog committed; page invalidation failed", {
module: "catalog",
error,
});
}
}