feat : copy buzzy audit logs
This commit is contained in:
+36
-144
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user