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

@@ -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);
});