feat(catalog): undo bulk offer edits with guarded history restoration
CI / check (push) Successful in 2m1s
CI / deploy (push) Successful in 18s
CI / publish-container (push) Successful in 1m28s

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();
});
@@ -27,6 +27,7 @@ const state = vi.hoisted(() => ({
],
queries: [] as string[],
writes: 0,
audit: [] as Array<{ before: string; after: string; target: string }>,
failAt: 0,
commits: 0,
rollbacks: 0,
@@ -36,11 +37,35 @@ vi.mock("@/lib/db", () => ({
execute: async () => [state.pages, []],
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const before = state.writes;
const beforeRows = structuredClone(state.rows);
const beforeAudit = [...state.audit];
try {
const result = await fn({
execute: async (query: SQL) => {
const text = new MySqlDialect().sqlToQuery(query).sql;
const { sql: text, params } = new MySqlDialect().sqlToQuery(query);
state.queries.push(text);
if (
text.startsWith("SELECT") &&
!text.includes("catalog_name") &&
text.includes("catalog_items")
) {
const row = state.rows.find(
(row) => row.id === Number(params.at(-1)),
);
return [
row
? [
{
pageId: String(row.pageId),
costCredits: row.costCredits,
costPoints: row.costPoints,
pointsType: row.pointsType,
},
]
: [],
[],
];
}
if (text.startsWith("SELECT"))
return [
text.includes("catalog_pages") ? state.pages : state.rows,
@@ -48,13 +73,41 @@ vi.mock("@/lib/db", () => ({
];
state.writes++;
if (state.writes === state.failAt) throw Error("write failed");
const row = state.rows.find(
(row) => row.id === Number(params.at(-1)),
);
if (row) {
const columns = {
page_id: "pageId",
cost_credits: "costCredits",
cost_points: "costPoints",
points_type: "pointsType",
} as const;
const assignments =
text.split(" SET ")[1]?.split(" WHERE ")[0] ?? "";
let index = 0;
for (const assignment of assignments.split(", ")) {
const name = assignment.match(
/`([^`]+)`/,
)?.[1] as keyof typeof columns;
if (columns[name]) row[columns[name]] = Number(params[index++]);
}
}
return [{ affectedRows: 1 }, []];
},
insert: () => ({
values: async (entry: (typeof state.audit)[number]) => {
state.audit.push(entry);
return [{ insertId: 1000 + state.audit.length }];
},
}),
});
state.commits++;
return result;
} catch (error) {
state.writes = before;
state.rows = beforeRows;
state.audit = beforeAudit;
state.rollbacks++;
throw error;
}
@@ -96,6 +149,7 @@ beforeEach(() => {
{ id: 9, caption: "Target" },
];
state.queries = [];
state.audit = [];
state.writes = 0;
state.failAt = 0;
state.commits = 0;
@@ -192,3 +246,21 @@ it("does not update offers whose chosen values already match", async () => {
await applyBulkOffersCommand(request, p.fingerprint);
expect(state.writes).toBe(0);
});
it("records category-only bulk edits as restorable history and returns durable IDs", async () => {
const request = { ids: [1, 2], changes: { pageId: 9 } };
const preview = await previewBulkOffersCommand(request);
expect(await applyBulkOffersCommand(request, preview.fingerprint, 7)).toEqual(
{ changedCount: 2, historyIds: [1001, 1002] },
);
expect(state.audit).toHaveLength(2);
expect(state.audit[0].target).toBe("catalog_offer");
expect(JSON.parse(state.audit[0].before)).toMatchObject({
pageId: "4",
costCredits: 3,
});
expect(JSON.parse(state.audit[0].after)).toMatchObject({
pageId: "9",
costCredits: 3,
});
});
+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 };
});
}
@@ -0,0 +1,111 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
current: [
{ pageId: "9", costCredits: 20, costPoints: 0, pointsType: 0 },
{ pageId: "9", costCredits: 30, costPoints: 0, pointsType: 0 },
],
audit: [] as unknown[],
queries: [] as string[],
}));
vi.mock("@/lib/db", () => ({
db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const saved = structuredClone(state.current);
const savedAudit = [...state.audit];
const tx = {
select: () => ({
from: () => ({
where: (query: SQL) => ({
limit: async () => {
const id = Number(
new MySqlDialect().sqlToQuery(query).params[0],
);
return [
{
id,
action: "history_update",
target: "catalog_offer",
targetId: id,
before: JSON.stringify({
pageId: "4",
costCredits: id * 10,
costPoints: 0,
pointsType: 0,
}),
after: JSON.stringify({
pageId: "9",
costCredits: (id + 1) * 10,
costPoints: 0,
pointsType: 0,
}),
},
];
},
}),
}),
}),
execute: async (query: SQL) => {
const { sql: text, params } = new MySqlDialect().sqlToQuery(query);
state.queries.push(text);
if (text.includes("catalog_pages")) return [[{ id: 4 }, { id: 9 }]];
const id = Number(params.at(-1));
if (text.startsWith("SELECT")) return [[state.current[id - 1]]];
state.current[id - 1] = {
pageId: String(params[0]),
costCredits: Number(params[1]),
costPoints: Number(params[2]),
pointsType: Number(params[3]),
};
return [{}];
},
insert: () => ({
values: async (entry: unknown) => {
state.audit.push(entry);
return [{ insertId: 100 + state.audit.length }];
},
}),
};
try {
return await fn(tx);
} catch (error) {
state.current = saved;
state.audit = savedAudit;
throw error;
}
},
},
}));
import { undoBulkOffersCommand } from "./bulk-offers";
beforeEach(() => {
state.current = [
{ pageId: "9", costCredits: 20, costPoints: 0, pointsType: 0 },
{ pageId: "9", costCredits: 30, costPoints: 0, pointsType: 0 },
];
state.audit = [];
state.queries = [];
});
it("restores the whole batch and records each restore in history", async () => {
expect(await undoBulkOffersCommand([2, 1], 7)).toEqual({ changedCount: 2 });
expect(state.current.map((row) => row.pageId)).toEqual(["4", "4"]);
expect(state.current.map((row) => row.costCredits)).toEqual([10, 20]);
expect(state.audit).toHaveLength(2);
expect(state.queries[0]).toContain("catalog_pages");
});
it("rolls back earlier restores and history when a later offer changed", async () => {
state.current[1].costPoints = 99;
await expect(undoBulkOffersCommand([1, 2], 7)).rejects.toThrow(/changed/);
expect(state.current[0].costCredits).toBe(20);
expect(state.current[1].costPoints).toBe(99);
expect(state.audit).toHaveLength(0);
});
it("rejects duplicate history IDs and repeated undo", async () => {
await expect(undoBulkOffersCommand([1, 1], 7)).rejects.toThrow(/Invalid/);
await undoBulkOffersCommand([1, 2], 7);
await expect(undoBulkOffersCommand([1, 2], 7)).rejects.toThrow(/changed/);
expect(state.audit).toHaveLength(2);
});
@@ -0,0 +1,54 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ db: {} }));
import { applyHistory, type HistoryTransaction } from "./server";
it("locks original and current categories before restoring offer prices and category", async () => {
const before = { pageId: "4", costCredits: 10, costPoints: 0, pointsType: 0 };
const after = { ...before, pageId: "9", costCredits: 20 };
let current = after;
const queries: string[] = [];
const tx = {
execute: async (query: SQL) => {
const text = new MySqlDialect().sqlToQuery(query).sql;
queries.push(text);
if (text.includes("catalog_pages")) return [[{ id: 4 }, { id: 9 }]];
if (text.startsWith("SELECT")) return [[current]];
current = before;
return [{}];
},
insert: () => ({ values: async () => [{ insertId: 17 }] }),
} as unknown as HistoryTransaction;
await applyHistory(
tx,
{ kind: "catalog_offer", targetId: 1, before, after },
7,
);
expect(queries[0]).toContain("catalog_pages");
expect(queries[0]).toContain("FOR UPDATE");
expect(queries.find((q) => q.startsWith("UPDATE"))).toContain("page_id");
await expect(
applyHistory(tx, { kind: "catalog_offer", targetId: 1, before, after }, 7),
).rejects.toThrow("conflict");
});
it("refuses restoration into deleted categories without writing", async () => {
const execute = vi.fn(async () => [[]]);
const tx = { execute } as unknown as HistoryTransaction;
const values = { pageId: "4", costCredits: 10, costPoints: 0, pointsType: 0 };
await expect(
applyHistory(
tx,
{
kind: "catalog_offer",
targetId: 1,
before: values,
after: { ...values, pageId: "9" },
},
7,
),
).rejects.toThrow("unavailable");
expect(execute).toHaveBeenCalledTimes(1);
});
+36 -2
View File
@@ -10,7 +10,12 @@ import {
import type { db } from "@/lib/db";
import { getOperationContext } from "@/lib/foundation/request-context";
import { type HistorySnapshot, sameSnapshot } from "./model";
export type HistoryKind = "category" | "category_bc" | "prices" | "news";
export type HistoryKind =
| "category"
| "category_bc"
| "prices"
| "catalog_offer"
| "news";
export type HistoryTransaction = Parameters<
Parameters<typeof db.transaction>[0]
>[0];
@@ -35,6 +40,10 @@ const categoryFields = [
"includes",
];
const configs = {
catalog_offer: {
table: CatalogItems,
fields: ["pageId", "costCredits", "costPoints", "pointsType"],
},
category: { table: CatalogPages, fields: categoryFields },
category_bc: { table: CatalogPagesBc, fields: categoryFields },
prices: {
@@ -113,7 +122,7 @@ export async function recordHistory(
Buffer.byteLength(serializedAfter, "utf8") > 16_000_000
)
throw Error("History snapshot is too large");
await tx.insert(AdminAuditLog).values({
const inserted = await tx.insert(AdminAuditLog).values({
userId,
action,
target: kind,
@@ -126,6 +135,7 @@ export async function recordHistory(
after: serializedAfter,
createdAt: new Date().toISOString(),
});
return inserted?.[0]?.insertId;
}
export async function readHistory(tx: HistoryTransaction, id: number) {
const [entry] = await tx
@@ -176,6 +186,7 @@ export async function applyHistory(
entry: Awaited<ReturnType<typeof readHistory>>,
userId: number,
) {
if (entry.kind === "catalog_offer") await lockOfferHistoryPages(tx, [entry]);
const current = await historySnapshot(tx, entry.kind, entry.targetId);
if (!sameSnapshot(current, entry.after)) throw Error("conflict");
const { table } = historyConfig(entry.kind);
@@ -197,3 +208,26 @@ export async function applyHistory(
"history_restore",
);
}
export async function lockOfferHistoryPages(
tx: HistoryTransaction,
entries: Array<Awaited<ReturnType<typeof readHistory>>>,
) {
const ids = [
...new Set(
entries.flatMap((entry) => [
Number(entry.before.pageId),
Number(entry.after.pageId),
]),
),
].sort((a, b) => a - b);
if (ids.some((id) => !Number.isSafeInteger(id) || id < 1))
throw Error("unavailable");
const [rows] = await tx.execute(
sql`SELECT id FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id FOR UPDATE`,
);
const found = new Set(
(rows as unknown as Array<{ id: number }>).map((row) => Number(row.id)),
);
if (ids.some((id) => !found.has(id))) throw Error("unavailable");
}