197 lines
5.3 KiB
TypeScript
197 lines
5.3 KiB
TypeScript
import "server-only";
|
|
import { eq, getTableColumns, sql } from "drizzle-orm";
|
|
import {
|
|
AdminAuditLog,
|
|
CatalogItems,
|
|
CatalogPages,
|
|
CatalogPagesBc,
|
|
WebsiteArticles,
|
|
} from "@/db/schema";
|
|
import type { db } from "@/lib/db";
|
|
import { type HistorySnapshot, sameSnapshot } from "./model";
|
|
export type HistoryKind = "category" | "category_bc" | "prices" | "news";
|
|
export type HistoryTransaction = Parameters<
|
|
Parameters<typeof db.transaction>[0]
|
|
>[0];
|
|
const categoryFields = [
|
|
"caption",
|
|
"captionSave",
|
|
"pageLayout",
|
|
"enabled",
|
|
"visible",
|
|
"clubOnly",
|
|
"vipOnly",
|
|
"minRank",
|
|
"iconImage",
|
|
"iconColor",
|
|
"pageHeadline",
|
|
"pageTeaser",
|
|
"pageSpecial",
|
|
"pageText1",
|
|
"pageText2",
|
|
"pageTextDetails",
|
|
"pageTextTeaser",
|
|
"includes",
|
|
];
|
|
const configs = {
|
|
category: { table: CatalogPages, fields: categoryFields },
|
|
category_bc: { table: CatalogPagesBc, fields: categoryFields },
|
|
prices: {
|
|
table: CatalogItems,
|
|
fields: ["costCredits", "costPoints", "pointsType"],
|
|
},
|
|
news: {
|
|
table: WebsiteArticles,
|
|
fields: [
|
|
"title",
|
|
"slug",
|
|
"image",
|
|
"shortStory",
|
|
"fullStory",
|
|
"status",
|
|
"publishAt",
|
|
"publishedAt",
|
|
],
|
|
},
|
|
};
|
|
export function historyConfig(kind: string) {
|
|
if (!Object.hasOwn(configs, kind)) throw Error("unavailable");
|
|
const config = configs[kind as HistoryKind];
|
|
const columns = getTableColumns(config.table);
|
|
return {
|
|
...config,
|
|
fields: config.fields.filter((key) => Object.hasOwn(columns, key)),
|
|
};
|
|
}
|
|
export async function historySnapshot(
|
|
tx: HistoryTransaction,
|
|
kind: HistoryKind,
|
|
id: number | string,
|
|
): Promise<HistorySnapshot> {
|
|
const { table, fields } = historyConfig(kind);
|
|
const columns = getTableColumns(table);
|
|
const selected = fields.map(
|
|
(key) =>
|
|
sql`${sql.identifier(columns[key as keyof typeof columns].name)} AS ${sql.identifier(key)}`,
|
|
);
|
|
const [rows] = await tx.execute(
|
|
sql`SELECT ${sql.join(selected, sql`, `)} FROM ${table} WHERE id=${id} FOR UPDATE`,
|
|
);
|
|
const row = (rows as unknown as Record<string, unknown>[])[0];
|
|
if (!row) throw Error("unavailable");
|
|
return Object.fromEntries(
|
|
fields.map((key) => {
|
|
const value = row[key];
|
|
return [
|
|
key,
|
|
value instanceof Date
|
|
? value.toISOString()
|
|
: value == null
|
|
? null
|
|
: typeof value === "number"
|
|
? value
|
|
: String(value),
|
|
];
|
|
}),
|
|
);
|
|
}
|
|
export async function recordHistory(
|
|
tx: HistoryTransaction,
|
|
kind: HistoryKind,
|
|
id: number | string,
|
|
userId: number,
|
|
before: HistorySnapshot,
|
|
action = "history_update",
|
|
) {
|
|
const after = await historySnapshot(tx, kind, id);
|
|
if (sameSnapshot(before, after)) return;
|
|
const serializedBefore = JSON.stringify(before);
|
|
const serializedAfter = JSON.stringify(after);
|
|
if (
|
|
Buffer.byteLength(serializedBefore, "utf8") > 16_000_000 ||
|
|
Buffer.byteLength(serializedAfter, "utf8") > 16_000_000
|
|
)
|
|
throw Error("History snapshot is too large");
|
|
await tx.insert(AdminAuditLog).values({
|
|
userId,
|
|
action,
|
|
target: kind,
|
|
targetId: Number(id) <= 2147483647 ? Number(id) : null,
|
|
details:
|
|
Number(id) > 2147483647 ? JSON.stringify({ targetId: String(id) }) : null,
|
|
before: serializedBefore,
|
|
after: serializedAfter,
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
export async function readHistory(tx: HistoryTransaction, id: number) {
|
|
const [entry] = await tx
|
|
.select()
|
|
.from(AdminAuditLog)
|
|
.where(eq(AdminAuditLog.id, id))
|
|
.limit(1);
|
|
if (!entry || !["history_update", "history_restore"].includes(entry.action))
|
|
throw Error("unavailable");
|
|
let targetId: number | string | null = entry.targetId;
|
|
if (targetId === null && entry.target === "news") {
|
|
const details = JSON.parse(entry.details ?? "null");
|
|
if (
|
|
typeof details?.targetId === "string" &&
|
|
/^[1-9]\d{0,19}$/.test(details.targetId)
|
|
)
|
|
targetId = details.targetId;
|
|
}
|
|
if (targetId === null) throw Error("unavailable");
|
|
const config = historyConfig(entry.target);
|
|
const parse = (raw: string | null): HistorySnapshot => {
|
|
const value: unknown = JSON.parse(raw ?? "null");
|
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
throw Error("unavailable");
|
|
const data = value as HistorySnapshot;
|
|
if (
|
|
Object.keys(data).length !== config.fields.length ||
|
|
!config.fields.every(
|
|
(key) =>
|
|
Object.hasOwn(data, key) &&
|
|
(data[key] === null ||
|
|
typeof data[key] === "string" ||
|
|
typeof data[key] === "number"),
|
|
)
|
|
)
|
|
throw Error("unavailable");
|
|
return data;
|
|
};
|
|
return {
|
|
kind: entry.target as HistoryKind,
|
|
targetId,
|
|
before: parse(entry.before),
|
|
after: parse(entry.after),
|
|
};
|
|
}
|
|
export async function applyHistory(
|
|
tx: HistoryTransaction,
|
|
entry: Awaited<ReturnType<typeof readHistory>>,
|
|
userId: number,
|
|
) {
|
|
const current = await historySnapshot(tx, entry.kind, entry.targetId);
|
|
if (!sameSnapshot(current, entry.after)) throw Error("conflict");
|
|
const { table } = historyConfig(entry.kind);
|
|
const columns = getTableColumns(table);
|
|
const assignments = Object.entries(entry.before).map(
|
|
([key, value]) =>
|
|
sql`${sql.identifier(columns[key as keyof typeof columns].name)}=${entry.kind === "news" && (key === "publishAt" || key === "publishedAt") && value ? new Date(String(value)) : value}`,
|
|
);
|
|
if (entry.kind === "news") assignments.push(sql`updated_at=${new Date()}`);
|
|
await tx.execute(
|
|
sql`UPDATE ${table} SET ${sql.join(assignments, sql`, `)} WHERE id=${entry.targetId}`,
|
|
);
|
|
await recordHistory(
|
|
tx,
|
|
entry.kind,
|
|
entry.targetId,
|
|
userId,
|
|
current,
|
|
"history_restore",
|
|
);
|
|
}
|