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