feat(catalog): undo bulk offer edits with guarded history restoration
This commit is contained in:
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();
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
Reference in new issue
Block a user