feat : copy buzzy audit logs
This commit is contained in:
+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