feat : copy buzzy audit logs
This commit is contained in:
+37
-145
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -376,7 +376,7 @@
|
||||
{
|
||||
"idx": 53,
|
||||
"version": "7",
|
||||
"when": 1790499026606,
|
||||
"when": 1790503002505,
|
||||
"tag": "0053_woozy_callisto",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
+12
-10
@@ -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
@@ -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
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user