feat : copy buzzy audit logs
CI / Verify (push) Successful in 2m2s
CI / Build immutable images and deploy (push) Successful in 2m47s

This commit is contained in:
2026-09-27 17:00:25 +07:00 Unverified
parent 8ddb5896b6
commit 188cec9bda
10 changed files with 152 additions and 353 deletions
+37 -145
View File
@@ -1,65 +1,20 @@
import { ClipboardListIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { connection } from "next/server";
import { ClipboardListIcon } from "lucide-react";
import { AdminHeader } from "@/components/admin/admin-header";
import { AuditLogFilters } from "@/components/admin/audit-log-filters";
import { Badge } from "@/components/ui/badge";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AUDIT_ACTION_LABELS,
AUDIT_TARGET_LABELS,
AUDIT_TARGET_TYPES,
isAuditTargetType,
listAuditActors,
listAuditLogs,
type AuditAction,
} from "@/lib/audit-log";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { listAuditActors, listAuditLogs } from "@/lib/audit-log";
import { getAdminSession } from "@/lib/auth/server";
function pageHref(
page: number,
actorId?: string,
targetType?: string,
) {
function pageHref(page: number, actorId?: string) {
const query = new URLSearchParams({ page: String(page) });
if (actorId) query.set("actor", actorId);
if (targetType) query.set("type", targetType);
return `/admin/activity?${query}`;
}
function eventSummary(metadata: Record<string, string | number | boolean | null>) {
const label = metadata.sectionTitle
?? metadata.guideName
?? metadata.alias
?? metadata.fileName
?? metadata.accountLabel;
if (typeof label === "string") return label;
if (typeof metadata.error === "string") return metadata.error;
return null;
}
export default async function AuditLogPage({
searchParams,
}: {
@@ -67,22 +22,15 @@ export default async function AuditLogPage({
}) {
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
const query = await searchParams;
const rawPage = Array.isArray(query.page) ? query.page[0] : query.page;
const parsedPage = Number(rawPage);
const page = Number.isSafeInteger(parsedPage) && parsedPage > 0
? parsedPage
: 1;
const page = Number.isSafeInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1;
const rawActorId = Array.isArray(query.actor) ? query.actor[0] : query.actor;
const actorId = rawActorId && rawActorId.length <= 128
? rawActorId
: undefined;
const rawTargetType = Array.isArray(query.type) ? query.type[0] : query.type;
const targetType = isAuditTargetType(rawTargetType)
? rawTargetType
: undefined;
const actorId = rawActorId && rawActorId.length <= 128 ? rawActorId : undefined;
const [{ events, pageCount, total }, actors] = await Promise.all([
listAuditLogs({ page, pageSize: 50, actorId, targetType }),
listAuditLogs({ page, pageSize: 100, actorId }),
listAuditActors(),
]);
const dateTime = new Intl.DateTimeFormat("th-TH", {
@@ -94,99 +42,47 @@ export default async function AuditLogPage({
return (
<div className="min-h-svh">
<AdminHeader />
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 sm:p-6 lg:p-8">
<main className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 sm:p-6 lg:p-8">
<header>
<p className="text-sm font-medium text-primary">Admin audit trail</p>
<h1 className="font-heading text-3xl font-semibold">Activity</h1>
<p className="text-sm font-medium text-primary">Admin audit log</p>
<h1 className="font-heading text-3xl font-semibold">Logs</h1>
<p className="mt-2 text-muted-foreground">
ประวัติการเปลี่ยนแปลงที่บันทึกสำเร็จทั้งหมด {total.toLocaleString("th-TH")} รายการ
บันทึกการทำงานของผู้ดูแล {total.toLocaleString("th-TH")} รายการ
</p>
</header>
<AuditLogFilters
key={`${actorId ?? "all"}:${targetType ?? "all"}`}
actors={actors}
targetTypes={AUDIT_TARGET_TYPES.map((value) => ({
value,
label: AUDIT_TARGET_LABELS[value],
}))}
initialActorId={actorId}
initialTargetType={targetType}
/>
<AuditLogFilters key={actorId ?? "all"} actors={actors} initialActorId={actorId} />
{events.length ? (
<>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>เวลา</TableHead>
<TableHead>ผู้ดูแล</TableHead>
<TableHead>การทำงาน</TableHead>
<TableHead>เป้าหมาย</TableHead>
<TableHead>รายละเอียด</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map((event) => {
const action = event.action as AuditAction;
const summary = eventSummary(event.metadata);
return (
<TableRow key={event.id}>
<TableCell>{dateTime.format(event.createdAt)}</TableCell>
<TableCell>{event.actorLabel}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span>{AUDIT_ACTION_LABELS[action] ?? event.action}</span>
{event.metadata.overwrite === true ? (
<Badge variant="outline">Overwrite</Badge>
) : null}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<Badge variant="secondary">
{isAuditTargetType(event.targetType)
? AUDIT_TARGET_LABELS[event.targetType]
: event.targetType}
</Badge>
<span className="max-w-48 truncate font-mono text-xs text-muted-foreground">
{event.targetId}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex max-w-80 flex-col gap-1">
{summary ? <span className="truncate">{summary}</span> : null}
<span className="text-xs text-muted-foreground">
{event.scope ? `${event.scope} · ` : ""}
{event.resultingVersion
? `v${event.resultingVersion}`
: ""}
</span>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="overflow-hidden rounded-xl border">
{events.map((event) => (
<article className="border-b px-4 py-3 last:border-b-0 hover:bg-muted/40" key={event.id}>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="font-semibold">{event.author ?? "System"}</span>
<span>{event.text}</span>
<time className="text-xs text-muted-foreground">{dateTime.format(event.time)}</time>
</div>
{event.details && Object.keys(event.details).length ? (
<details className="mt-2">
<summary className="cursor-pointer text-sm text-muted-foreground">รายละเอียด</summary>
<pre className="mt-2 max-h-96 overflow-auto rounded-lg bg-muted p-3 text-xs">
{JSON.stringify(event.details, null, 2)}
</pre>
</details>
) : null}
</article>
))}
</div>
{pageCount > 1 ? (
<Pagination>
<PaginationContent>
{page > 1 ? (
<PaginationItem>
<PaginationPrevious
href={pageHref(page - 1, actorId, targetType)}
text="ก่อนหน้า"
/>
<PaginationPrevious href={pageHref(page - 1, actorId)} text="ก่อนหน้า" />
</PaginationItem>
) : null}
{page < pageCount ? (
<PaginationItem>
<PaginationNext
href={pageHref(page + 1, actorId, targetType)}
text="ถัดไป"
/>
<PaginationNext href={pageHref(page + 1, actorId)} text="ถัดไป" />
</PaginationItem>
) : null}
</PaginationContent>
@@ -196,13 +92,9 @@ export default async function AuditLogPage({
) : (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<ClipboardListIcon />
</EmptyMedia>
<EmptyTitle>ยังไม่มีกิจกรรม</EmptyTitle>
<EmptyDescription>
ลองเปลี่ยนตัวกรอง หรือกลับมาหลังจากมีการแก้ไขข้อมูล
</EmptyDescription>
<EmptyMedia variant="icon"><ClipboardListIcon /></EmptyMedia>
<EmptyTitle>ยังไม่มี Log</EmptyTitle>
<EmptyDescription>Log จะแสดงหลังจากผู้ดูแลแก้ไขข้อมูล</EmptyDescription>
</EmptyHeader>
</Empty>
)}
-25
View File
@@ -19,23 +19,17 @@ const ALL = "all";
export function AuditLogFilters({
actors,
targetTypes,
initialActorId,
initialTargetType,
}: {
actors: Array<{ id: string; label: string }>;
targetTypes: Array<{ value: string; label: string }>;
initialActorId?: string;
initialTargetType?: string;
}) {
const router = useRouter();
const [actorId, setActorId] = useState(initialActorId ?? ALL);
const [targetType, setTargetType] = useState(initialTargetType ?? ALL);
function applyFilters() {
const query = new URLSearchParams();
if (actorId !== ALL) query.set("actor", actorId);
if (targetType !== ALL) query.set("type", targetType);
const suffix = query.toString();
router.push(suffix ? `/admin/activity?${suffix}` : "/admin/activity");
}
@@ -60,24 +54,6 @@ export function AuditLogFilters({
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="audit-target-type">ประเภทเป้าหมาย</FieldLabel>
<Select value={targetType} onValueChange={(value) => value && setTargetType(value)}>
<SelectTrigger id="audit-target-type" className="w-full md:w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={ALL}>ทั้งหมด</SelectItem>
{targetTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<div className="flex gap-2">
<Button type="button" onClick={applyFilters}>
<FilterIcon data-icon="inline-start" />
@@ -88,7 +64,6 @@ export function AuditLogFilters({
variant="outline"
onClick={() => {
setActorId(ALL);
setTargetType(ALL);
router.push("/admin/activity");
}}
>
+18 -27
View File
@@ -23,6 +23,14 @@ import type {
} from "@/lib/catalog/lunaris";
import type { ExtraSheet } from "@/lib/guides/extra-sheet";
export type AuditLogValue =
| string
| number
| boolean
| null
| AuditLogValue[]
| { [key: string]: AuditLogValue };
const authSchema = pgSchema("auth");
const catalogSchema = pgSchema("catalog");
const guidesSchema = pgSchema("guides");
@@ -177,42 +185,25 @@ export const outboxEvents = pgTable(
],
);
export const auditLogs = pgTable(
export const auditLog = pgTable(
"audit_log",
{
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedAlwaysAsIdentity(),
actorId: text("actor_id").notNull(),
actorLabel: text("actor_label").notNull(),
action: varchar("action", { length: 64 }).notNull(),
targetType: varchar("target_type", { length: 32 }).notNull(),
targetId: text("target_id").notNull(),
scope: varchar("scope", { length: 64 }),
resultingVersion: integer("resulting_version"),
metadata: jsonb("metadata")
.$type<Record<string, string | number | boolean | null>>()
.default({})
.notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
authorId: text("author_id"),
author: text("author"),
text: text("text").notNull(),
details: jsonb("details")
.$type<Record<string, AuditLogValue>>()
.default({}),
time: timestamp("time", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [
index("audit_log_created_idx").on(table.createdAt, table.id),
index("audit_log_actor_created_idx").on(
table.actorId,
table.createdAt,
),
index("audit_log_target_created_idx").on(
table.targetType,
table.targetId,
table.createdAt,
),
index("audit_log_action_created_idx").on(
table.action,
table.createdAt,
),
index("audit_log_time_idx").on(table.time, table.id),
index("audit_log_author_time_idx").on(table.authorId, table.time),
],
);
+7 -13
View File
@@ -1,17 +1,11 @@
CREATE TABLE "audit_log" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "audit_log_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"actor_id" text NOT NULL,
"actor_label" text NOT NULL,
"action" varchar(64) NOT NULL,
"target_type" varchar(32) NOT NULL,
"target_id" text NOT NULL,
"scope" varchar(64),
"resulting_version" integer,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
"author_id" text,
"author" text,
"text" text NOT NULL,
"details" jsonb DEFAULT '{}'::jsonb,
"time" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "audit_log_created_idx" ON "audit_log" USING btree ("created_at","id");--> statement-breakpoint
CREATE INDEX "audit_log_actor_created_idx" ON "audit_log" USING btree ("actor_id","created_at");--> statement-breakpoint
CREATE INDEX "audit_log_target_created_idx" ON "audit_log" USING btree ("target_type","target_id","created_at");--> statement-breakpoint
CREATE INDEX "audit_log_action_created_idx" ON "audit_log" USING btree ("action","created_at");
CREATE INDEX "audit_log_time_idx" ON "audit_log" USING btree ("time","id");--> statement-breakpoint
CREATE INDEX "audit_log_author_time_idx" ON "audit_log" USING btree ("author_id","time");
+24 -96
View File
@@ -1,5 +1,5 @@
{
"id": "cdfff9d0-72b6-44bc-9af7-79b7995f9d49",
"id": "74274cf2-88e8-40cf-84db-3333cecc34cf",
"prevId": "06235585-b969-474c-8f40-ba3a55869d1d",
"version": "7",
"dialect": "postgresql",
@@ -538,57 +538,33 @@
"cycle": false
}
},
"actor_id": {
"name": "actor_id",
"author_id": {
"name": "author_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_label": {
"name": "actor_label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"action": {
"name": "action",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"target_type": {
"name": "target_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"target_id": {
"name": "target_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"resulting_version": {
"name": "resulting_version",
"type": "integer",
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
},
"details": {
"name": "details",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"notNull": false,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"time": {
"name": "time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
@@ -596,11 +572,11 @@
}
},
"indexes": {
"audit_log_created_idx": {
"name": "audit_log_created_idx",
"audit_log_time_idx": {
"name": "audit_log_time_idx",
"columns": [
{
"expression": "created_at",
"expression": "time",
"isExpression": false,
"asc": true,
"nulls": "last"
@@ -617,65 +593,17 @@
"method": "btree",
"with": {}
},
"audit_log_actor_created_idx": {
"name": "audit_log_actor_created_idx",
"audit_log_author_time_idx": {
"name": "audit_log_author_time_idx",
"columns": [
{
"expression": "actor_id",
"expression": "author_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_target_created_idx": {
"name": "audit_log_target_created_idx",
"columns": [
{
"expression": "target_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "target_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_log_action_created_idx": {
"name": "audit_log_action_created_idx",
"columns": [
{
"expression": "action",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"expression": "time",
"isExpression": false,
"asc": true,
"nulls": "last"
+1 -1
View File
@@ -376,7 +376,7 @@
{
"idx": 53,
"version": "7",
"when": 1790499026606,
"when": 1790503002505,
"tag": "0053_woozy_callisto",
"breakpoints": true
}
+12 -10
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
import { auditLogs } from "@/db/schema";
import { auditLog } from "@/db/schema";
import {
AUDIT_ACTION_LABELS,
AUDIT_ACTIONS,
@@ -45,16 +45,18 @@ describe("audit log", () => {
},
);
expect(insert).toHaveBeenCalledWith(auditLogs);
expect(insert).toHaveBeenCalledWith(auditLog);
expect(values).toHaveBeenCalledWith({
actorId: "admin-1",
actorLabel: "Editor",
action: "guide.weapon.saved",
targetType: "guide",
targetId: "guide-1",
scope: "weapon",
resultingVersion: 4,
metadata: { guideName: "Amber", overwrite: true },
authorId: "admin-1",
author: "Editor",
text: "บันทึก Weapons",
details: {
target: { type: "guide", id: "guide-1" },
scope: "weapon",
version: 4,
guideName: "Amber",
overwrite: true,
},
});
});
});
+34 -32
View File
@@ -1,9 +1,9 @@
import "server-only";
import { and, count, desc, eq } from "drizzle-orm";
import { count, desc, eq } from "drizzle-orm";
import { getDb, type Database } from "@/db";
import { auditLogs } from "@/db/schema";
import { auditLog as auditLogTable, type AuditLogValue } from "@/db/schema";
import { securityLog } from "@/lib/security/http";
export const AUDIT_ACTIONS = [
@@ -47,10 +47,7 @@ export const AUDIT_TARGET_TYPES = [
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number];
export type AuditMetadata = Record<
string,
string | number | boolean | null
>;
export type AuditMetadata = Record<string, AuditLogValue>;
export interface AuditActor {
id: string;
@@ -84,15 +81,21 @@ export async function writeAuditLog(
actor: AuditActor,
event: AuditLogInput,
): Promise<void> {
await writer.insert(auditLogs).values({
actorId: actor.id,
actorLabel: actor.label,
action: event.action,
targetType: event.targetType,
targetId: event.targetId,
scope: event.scope,
resultingVersion: event.resultingVersion,
metadata: event.metadata ?? {},
await writer.insert(auditLogTable).values({
authorId: actor.id,
author: actor.label,
text: AUDIT_ACTION_LABELS[event.action],
details: {
target: {
type: event.targetType,
id: event.targetId,
},
...(event.scope ? { scope: event.scope } : {}),
...(event.resultingVersion
? { version: event.resultingVersion }
: {}),
...event.metadata,
},
});
}
@@ -112,7 +115,6 @@ export async function writeAuditLogBestEffort(
export interface AuditLogFilters {
actorId?: string;
targetType?: AuditTargetType;
page?: number;
pageSize?: number;
}
@@ -120,25 +122,21 @@ export interface AuditLogFilters {
export async function listAuditLogs(filters: AuditLogFilters = {}) {
const page = Math.max(1, Math.floor(filters.page ?? 1));
const pageSize = Math.max(1, Math.min(100, Math.floor(filters.pageSize ?? 50)));
const conditions = [
filters.actorId ? eq(auditLogs.actorId, filters.actorId) : undefined,
filters.targetType
? eq(auditLogs.targetType, filters.targetType)
: undefined,
].filter((condition) => condition !== undefined);
const where = conditions.length ? and(...conditions) : undefined;
const where = filters.actorId
? eq(auditLogTable.authorId, filters.actorId)
: undefined;
const db = getDb();
const [events, [total]] = await Promise.all([
db
.select()
.from(auditLogs)
.from(auditLogTable)
.where(where)
.orderBy(desc(auditLogs.createdAt), desc(auditLogs.id))
.orderBy(desc(auditLogTable.time), desc(auditLogTable.id))
.limit(pageSize)
.offset((page - 1) * pageSize),
db
.select({ value: count() })
.from(auditLogs)
.from(auditLogTable)
.where(where),
]);
return {
@@ -152,13 +150,17 @@ export async function listAuditLogs(filters: AuditLogFilters = {}) {
export async function listAuditActors() {
const rows = await getDb()
.selectDistinctOn([auditLogs.actorId], {
id: auditLogs.actorId,
label: auditLogs.actorLabel,
.selectDistinctOn([auditLogTable.authorId], {
id: auditLogTable.authorId,
label: auditLogTable.author,
})
.from(auditLogs)
.orderBy(auditLogs.actorId, desc(auditLogs.id));
return rows.sort((left, right) =>
.from(auditLogTable)
.orderBy(auditLogTable.authorId, desc(auditLogTable.id));
const actors = rows.filter(
(row): row is { id: string; label: string } =>
Boolean(row.id && row.label),
);
return actors.sort((left, right) =>
left.label.localeCompare(right.label, "th"),
);
}
+10 -1
View File
@@ -691,6 +691,7 @@ export async function cancelCatalogSyncJob(
action: "catalog_sync.cancel_requested",
targetType: "catalog_sync",
targetId: id,
metadata: { status: job.status },
});
return writeSyncJob({ ...job, message: "กำลังยกเลิก Sync…", status: "cancelled" });
}
@@ -701,6 +702,7 @@ export async function startCatalogSyncJob(actor: AuditActor): Promise<CatalogSyn
action: "catalog_sync.requested",
targetType: "catalog_sync",
targetId: id,
metadata: { status: "queued" },
});
const job = await writeSyncJob({ id, status: "queued", phase: "queued", completed: 0, total: 0, message: "กำลังเตรียม Sync" });
void executeCatalogSyncJob(job, actor).catch(() => undefined);
@@ -738,6 +740,9 @@ async function executeCatalogSyncJob(initial: CatalogSyncJob, actor: AuditActor)
: "catalog_sync.completed",
targetType: "catalog_sync",
targetId: initial.id,
metadata: {
status: result === "unchanged" ? "unchanged" : "completed",
},
});
} catch (cause) {
if (cause instanceof Error && cause.message === "CATALOG_SYNC_CANCELLED") {
@@ -746,6 +751,7 @@ async function executeCatalogSyncJob(initial: CatalogSyncJob, actor: AuditActor)
action: "catalog_sync.cancelled",
targetType: "catalog_sync",
targetId: initial.id,
metadata: { status: "cancelled" },
});
} else {
const error = cause instanceof Error ? cause.message : "Unknown sync error";
@@ -754,7 +760,10 @@ async function executeCatalogSyncJob(initial: CatalogSyncJob, actor: AuditActor)
action: "catalog_sync.failed",
targetType: "catalog_sync",
targetId: initial.id,
metadata: { error: error.slice(0, 500) },
metadata: {
error: error.slice(0, 500),
status: "error",
},
});
}
}
+8 -2
View File
@@ -44,6 +44,7 @@ import {
writeAuditLog,
type AuditAction,
type AuditActor,
type AuditMetadata,
} from "@/lib/audit-log";
const DEFAULT_SECTIONS = [
@@ -77,6 +78,7 @@ async function auditGuide(
action: AuditAction,
guide: { id: string; name: string; characterKey: string; version: number },
scope?: AdminEditorScope,
metadata: AuditMetadata = {},
) {
await writeAuditLog(tx, context.actor, {
action,
@@ -88,6 +90,7 @@ async function auditGuide(
guideName: guide.name,
characterKey: guide.characterKey,
...(context.overwrite ? { overwrite: true } : {}),
...metadata,
},
});
}
@@ -484,7 +487,8 @@ export async function reorderExtraSections(
const existing = await tx
.select({ id: guideSections.id })
.from(guideSections)
.where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra")));
.where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra")))
.orderBy(asc(guideSections.sortOrder));
const existingIds = new Set(existing.map((section) => section.id));
if (
existingIds.size !== data.sectionIds.length ||
@@ -500,7 +504,9 @@ export async function reorderExtraSections(
.set({ sortOrder: 4 + index })
.where(eq(guideSections.id, sectionId));
}
await auditGuide(tx, context, "guide.extra.reordered", updated, "extras:list");
await auditGuide(tx, context, "guide.extra.reordered", updated, "extras:list", {
sectionIds: data.sectionIds,
});
return updated;
});
}