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
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user