feat(cms): add recovery history and operational reliability tools
This commit is contained in:
1 parent
89526af344
commit
1ef405be0a
92 files changed
+6384
-267
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { recordHistory } from "@/features/history/server";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
type BulkOfferInput,
|
||||
@@ -108,6 +109,7 @@ export async function previewBulkOffersCommand(
|
||||
export async function applyBulkOffersCommand(
|
||||
value: BulkOfferInput,
|
||||
fingerprint: string,
|
||||
userId?: number,
|
||||
) {
|
||||
const input = bulkOfferInputSchema.parse(value);
|
||||
if (!/^[a-f0-9]{64}$/.test(fingerprint))
|
||||
@@ -144,6 +146,12 @@ 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,
|
||||
});
|
||||
}
|
||||
return { changedCount: result.changedCount };
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import { getTableColumns, type SQL, sql } from "drizzle-orm";
|
||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
|
||||
import {
|
||||
distinctOfferIds,
|
||||
@@ -79,7 +80,10 @@ function assignments(
|
||||
return sql`${sql.identifier(column.name)}=${key === "pageId" ? String(value) : value}`;
|
||||
});
|
||||
}
|
||||
export async function updateOfferCommand(input: UpdateOfferInput) {
|
||||
export async function updateOfferCommand(
|
||||
input: UpdateOfferInput,
|
||||
userId?: number,
|
||||
) {
|
||||
offerIdSchema.parse(input.id);
|
||||
const fields = offerPatchSchema.parse(input.catalogFields);
|
||||
const baseFields = input.baseItem
|
||||
@@ -95,6 +99,9 @@ export async function updateOfferCommand(input: UpdateOfferInput) {
|
||||
// Page locks precede offer locks, matching category deletion's lock order.
|
||||
if (fields.pageId !== undefined) await lockPage(tx, fields.pageId);
|
||||
const [offer] = await lockedOffers(tx, [input.id]);
|
||||
const before = userId
|
||||
? await historySnapshot(tx, "prices", input.id)
|
||||
: null;
|
||||
if (input.baseItem) {
|
||||
const currentIds = String(offer.itemIds).split(";").map(Number);
|
||||
const nextIds =
|
||||
@@ -121,6 +128,8 @@ export async function updateOfferCommand(input: UpdateOfferInput) {
|
||||
await tx.execute(
|
||||
sql`UPDATE ${ItemsBase} SET ${sql.join(baseAssignments, sql`, `)} WHERE id=${input.baseItem.id}`,
|
||||
);
|
||||
if (before && userId)
|
||||
await recordHistory(tx, "prices", input.id, userId, before);
|
||||
});
|
||||
}
|
||||
export async function reorderOffersCommand(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
||||
import { remapIncludes } from "../domain/duplicate";
|
||||
@@ -261,6 +262,7 @@ async function updateRow(
|
||||
}
|
||||
async function publishCatalogPackageAttempt(
|
||||
raw: PublishCatalogPackageInput,
|
||||
userId?: number,
|
||||
): Promise<PackagePublication> {
|
||||
const input = publishPackageSchema.parse(raw);
|
||||
const initial = await getCatalogPackageCommand(input.id);
|
||||
@@ -292,21 +294,32 @@ async function publishCatalogPackageAttempt(
|
||||
const pageIds: number[] = [];
|
||||
if (p.mode === "update") {
|
||||
for (const page of p.draft.pages) {
|
||||
const historyBefore = userId
|
||||
? await historySnapshot(tx, "category", page.id)
|
||||
: null;
|
||||
await updateRow(
|
||||
tx,
|
||||
"catalog_pages",
|
||||
page.id,
|
||||
columns(page, pageColumns),
|
||||
);
|
||||
if (historyBefore && userId)
|
||||
await recordHistory(tx, "category", page.id, userId, historyBefore);
|
||||
pageIds.push(page.id);
|
||||
}
|
||||
for (const offer of p.draft.offers)
|
||||
for (const offer of p.draft.offers) {
|
||||
const historyBefore = userId
|
||||
? await historySnapshot(tx, "prices", offer.id)
|
||||
: null;
|
||||
await updateRow(
|
||||
tx,
|
||||
"catalog_items",
|
||||
offer.id,
|
||||
columns(offer, offerColumns),
|
||||
);
|
||||
if (historyBefore && userId)
|
||||
await recordHistory(tx, "prices", offer.id, userId, historyBefore);
|
||||
}
|
||||
} else {
|
||||
if (reserved.length !== source.offers.length)
|
||||
throw new CatalogConflict(
|
||||
@@ -399,10 +412,11 @@ async function publishCatalogPackageAttempt(
|
||||
/** An ID reserved by another process can race our process-local allocator. A collision rolls back the entire copy before retry. */
|
||||
export async function publishCatalogPackageCommand(
|
||||
input: PublishCatalogPackageInput,
|
||||
userId?: number,
|
||||
): Promise<PackagePublication> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await publishCatalogPackageAttempt(input);
|
||||
return await publishCatalogPackageAttempt(input, userId);
|
||||
} catch (error) {
|
||||
let cause: unknown = error,
|
||||
duplicate = false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import { getTableColumns, type SQL, sql } from "drizzle-orm";
|
||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||
import { CatalogPages, CatalogPagesBc, db } from "@/lib/db";
|
||||
import {
|
||||
assertParent,
|
||||
@@ -53,6 +54,7 @@ export async function updatePageCommand(
|
||||
id: number,
|
||||
fields: Record<string, unknown>,
|
||||
expected?: Record<string, unknown>,
|
||||
userId?: number,
|
||||
) {
|
||||
positiveId(id);
|
||||
const data = pagePatchSchema.parse(fields);
|
||||
@@ -76,6 +78,8 @@ export async function updatePageCommand(
|
||||
);
|
||||
}
|
||||
}
|
||||
const historyKind = kind === "bc" ? "category_bc" : "category";
|
||||
const before = userId ? await historySnapshot(tx, historyKind, id) : null;
|
||||
if (data.parentId !== undefined) assertParent(rows, id, data.parentId);
|
||||
const assignments = fieldsSql(kind, data);
|
||||
if (!assignments.length)
|
||||
@@ -83,6 +87,8 @@ export async function updatePageCommand(
|
||||
await tx.execute(
|
||||
sql`UPDATE ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)} WHERE id=${id}`,
|
||||
);
|
||||
if (before && userId)
|
||||
await recordHistory(tx, historyKind, id, userId, before);
|
||||
});
|
||||
}
|
||||
export async function createPageCommand(
|
||||
@@ -153,6 +159,7 @@ export async function togglePageCommand(
|
||||
kind: CatalogKind,
|
||||
id: number,
|
||||
field: "enabled" | "visible",
|
||||
userId?: number,
|
||||
) {
|
||||
positiveId(id);
|
||||
if (field !== "enabled" && field !== "visible")
|
||||
@@ -161,9 +168,13 @@ export async function togglePageCommand(
|
||||
const rows = await lockedPages(tx, kind);
|
||||
if (!rows.some((row) => row.id === id))
|
||||
throw new CatalogNotFound("Catalog page not found");
|
||||
const historyKind = kind === "bc" ? "category_bc" : "category";
|
||||
const before = userId ? await historySnapshot(tx, historyKind, id) : null;
|
||||
const column = sql.identifier(field);
|
||||
await tx.execute(
|
||||
sql`UPDATE ${tables(kind).pages} SET ${column}=CASE WHEN ${column}='1' THEN '0' ELSE '1' END WHERE id=${id}`,
|
||||
);
|
||||
if (before && userId)
|
||||
await recordHistory(tx, historyKind, id, userId, before);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user