feat(catalog): undo bulk offer edits with guarded history restoration

This commit is contained in:
Simo committed 2026-09-11 10:47:46 +02:00
1 parent 13bd3695cb
commit c5a2b44807
33 files changed
+1399 -116

No files matched your search

@@ -4,7 +4,11 @@ import { ArrowRight, Loader2, SlidersHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { applyBulkOffers, previewBulkOffers } from "@/actions/catalog-bulk";
import {
applyBulkOffers,
previewBulkOffers,
undoBulkOffers,
} from "@/actions/catalog-bulk";
import { MovePageCombobox } from "@/app/admin/catalog/[id]/catalog-items-table/field-helpers";
import { POINTS_TYPES } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import {
@@ -174,7 +178,34 @@ export function BulkOfferEditor({
setPreview(null);
return;
}
toast.success(t("success", { count: result.data.changedCount }));
toast.success(t("success", { count: result.data.changedCount }), {
duration: 15000,
description: t("undoHint"),
action: result.data.historyIds.length
? {
label: t("undo"),
onClick: async () => {
if (busyRef.current || (beforeEdit && !beforeEdit())) return;
busyRef.current = true;
try {
const restored = await undoBulkOffers(result.data.historyIds);
if (!restored.ok) {
toast.error(restored.error);
return;
}
toast.success(
t("undoSuccess", { count: restored.data.changedCount }),
);
await onApplied();
} catch {
toast.error(t("requestFailed"));
} finally {
busyRef.current = false;
}
},
}
: undefined,
});
setOpen(false);
await onApplied();
} catch {
@@ -5,6 +5,7 @@ const state = vi.hoisted(() => ({
preview: vi.fn(),
destinations: vi.fn(),
apply: vi.fn(),
undo: vi.fn(),
export: vi.fn(),
send: vi.fn(),
audit: vi.fn(),
@@ -18,6 +19,7 @@ vi.mock("@/features/catalog/server/bulk-offers", () => ({
previewBulkOffersCommand: state.preview,
listBulkOfferDestinationsCommand: state.destinations,
applyBulkOffersCommand: state.apply,
undoBulkOffersCommand: state.undo,
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: state.export,
@@ -33,6 +35,7 @@ import {
applyBulkOffers,
getBulkOfferDestinations,
previewBulkOffers,
undoBulkOffers,
} from "@/actions/catalog-bulk";
const input = { ids: [1], changes: { pageId: 2 } };
@@ -87,3 +90,20 @@ it("loads destination choices with view permission only", async () => {
expect(state.permission).toHaveBeenCalledWith("view");
expect(state.export).not.toHaveBeenCalled();
});
it("undo enforces edit access and exports only successful restores", async () => {
state.undo.mockResolvedValueOnce({ changedCount: 2 });
expect((await undoBulkOffers([10, 11])).ok).toBe(true);
expect(state.permission).toHaveBeenCalledWith("edit");
expect(state.undo).toHaveBeenCalledWith([10, 11], 1);
expect(state.export).toHaveBeenCalledTimes(1);
expect(state.send).toHaveBeenCalledTimes(1);
});
it("undo denial stops before export and a conflict does not notify", async () => {
state.permission.mockRejectedValueOnce(Error("Denied"));
await expect(undoBulkOffers([10])).rejects.toThrow("Denied");
expect(state.export).not.toHaveBeenCalled();
state.undo.mockRejectedValueOnce(Error("conflict"));
expect((await undoBulkOffers([10])).ok).toBe(false);
expect(state.send).not.toHaveBeenCalled();
});
File diff suppressed because it is too large. Load diff
+59 -8
View File
@@ -1,7 +1,12 @@
import "server-only";
import { createHash } from "node:crypto";
import { sql } from "drizzle-orm";
import { recordHistory } from "@/features/history/server";
import {
applyHistory,
lockOfferHistoryPages,
readHistory,
recordHistory,
} from "@/features/history/server";
import { db } from "@/lib/db";
import {
type BulkOfferInput,
@@ -132,6 +137,7 @@ export async function applyBulkOffersCommand(
costPoints: "cost_points",
pointsType: "points_type",
};
const historyIds: number[] = [];
for (const row of result.rows) {
const changes = (
Object.keys(input.changes) as Array<keyof BulkOfferValues>
@@ -146,14 +152,22 @@ export async function applyBulkOffersCommand(
sql`, `,
)} WHERE id=${row.id}`,
);
if (userId)
await recordHistory(tx, "prices", row.id, userId, {
costCredits: row.before.costCredits,
costPoints: row.before.costPoints,
pointsType: row.before.pointsType,
});
if (userId) {
const historyId = await recordHistory(
tx,
"catalog_offer",
row.id,
userId,
{
...row.before,
pageId: String(row.before.pageId),
},
);
if (!historyId) throw Error("History entry could not be recorded");
historyIds.push(historyId);
}
}
return { changedCount: result.changedCount };
return { changedCount: result.changedCount, historyIds };
});
}
@@ -170,3 +184,40 @@ export async function listBulkOfferDestinationsCommand() {
);
return { pages };
}
export async function undoBulkOffersCommand(
historyIds: number[],
userId: number,
) {
if (
!Array.isArray(historyIds) ||
!historyIds.length ||
historyIds.length > 500 ||
new Set(historyIds).size !== historyIds.length ||
historyIds.some((id) => !Number.isSafeInteger(id) || id < 1)
)
throw new CatalogInputError("Invalid undo selection");
return db.transaction(async (tx) => {
const entries = [];
for (const id of historyIds) {
const entry = await readHistory(tx, id);
if (entry.kind !== "catalog_offer")
throw new CatalogInputError("Invalid undo selection");
entries.push(entry);
}
entries.sort((a, b) => Number(a.targetId) - Number(b.targetId));
if (new Set(entries.map((entry) => entry.targetId)).size !== entries.length)
throw new CatalogInputError("Duplicate undo target");
await lockOfferHistoryPages(tx, entries);
try {
for (const entry of entries) await applyHistory(tx, entry, userId);
} catch (error) {
if (error instanceof Error && error.message === "conflict")
throw new CatalogConflict(
"Offers changed after this update. Undo was not applied.",
);
throw error;
}
return { changedCount: entries.length };
});
}
File diff suppressed because it is too large. Load diff