Files
Epicnabbo-Catalogus-Updated…/src/features/history/server.ts
T

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",
);
}