import "server-only"; import { count, desc, eq } from "drizzle-orm"; import { getDb, type Database } from "@/db"; import { auditLog as auditLogTable, type AuditLogValue } from "@/db/schema"; import { securityLog } from "@/lib/security/http"; export const AUDIT_ACTIONS = [ "guide.created", "guide.overview.saved", "guide.weapon.saved", "guide.artifact.saved", "guide.constellations.saved", "guide.team.saved", "guide.extra.created", "guide.extra.saved", "guide.extra.deleted", "guide.extra.reordered", "guide.trashed", "guide.restored", "guide.published", "guide.unpublished", "guide.permanently_deleted", "glossary_alias.created", "glossary_alias.deleted", "media.uploaded", "media.deleted", "admin_account.created", "admin_account.removed", "catalog_sync.requested", "catalog_sync.cancel_requested", "catalog_sync.completed", "catalog_sync.unchanged", "catalog_sync.cancelled", "catalog_sync.failed", ] as const; export const AUDIT_TARGET_TYPES = [ "guide", "extra_section", "glossary_alias", "media", "admin_account", "catalog_sync", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number]; export type AuditMetadata = Record; export interface AuditActor { id: string; label: string; } export interface AuditLogInput { action: AuditAction; targetType: AuditTargetType; targetId: string; scope?: string; resultingVersion?: number; metadata?: AuditMetadata; } type AuditWriter = Pick; export function auditActor(user: { id: string; name?: string | null; email?: string | null; }): AuditActor { return { id: user.id, label: (user.name?.trim() || user.email?.trim() || user.id).slice(0, 160), }; } export async function writeAuditLog( writer: AuditWriter, actor: AuditActor, event: AuditLogInput, ): Promise { 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, }, }); } export async function writeAuditLogBestEffort( actor: AuditActor, event: AuditLogInput, ): Promise { try { await writeAuditLog(getDb(), actor, event); } catch { securityLog("audit-write-failed", { actorId: actor.id, targetId: event.targetId, }); } } export interface AuditLogFilters { actorId?: string; page?: number; pageSize?: number; } 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 where = filters.actorId ? eq(auditLogTable.authorId, filters.actorId) : undefined; const db = getDb(); const [events, [total]] = await Promise.all([ db .select() .from(auditLogTable) .where(where) .orderBy(desc(auditLogTable.time), desc(auditLogTable.id)) .limit(pageSize) .offset((page - 1) * pageSize), db .select({ value: count() }) .from(auditLogTable) .where(where), ]); return { events, page, pageSize, total: total.value, pageCount: Math.max(1, Math.ceil(total.value / pageSize)), }; } export async function listAuditActors() { const rows = await getDb() .selectDistinctOn([auditLogTable.authorId], { id: auditLogTable.authorId, label: auditLogTable.author, }) .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"), ); } export const AUDIT_TARGET_LABELS: Record< AuditTargetType, string > = { guide: "Guide", extra_section: "Extra section", glossary_alias: "Glossary", media: "Media", admin_account: "Admin account", catalog_sync: "Catalog sync", }; export const AUDIT_ACTION_LABELS: Record = { "guide.created": "สร้าง Guide", "guide.overview.saved": "บันทึก Overview", "guide.weapon.saved": "บันทึก Weapons", "guide.artifact.saved": "บันทึก Artifacts", "guide.constellations.saved": "บันทึก Constellations", "guide.team.saved": "บันทึก Team", "guide.extra.created": "สร้าง Extra section", "guide.extra.saved": "บันทึก Extra section", "guide.extra.deleted": "ลบ Extra section", "guide.extra.reordered": "เรียง Extra sections", "guide.trashed": "ย้าย Guide ไปถังขยะ", "guide.restored": "กู้คืน Guide", "guide.published": "เผยแพร่ Guide", "guide.unpublished": "ยกเลิกเผยแพร่ Guide", "guide.permanently_deleted": "ลบ Guide ถาวร", "glossary_alias.created": "เพิ่ม Glossary shortcut", "glossary_alias.deleted": "ลบ Glossary shortcut", "media.uploaded": "อัปโหลด Media", "media.deleted": "ลบ Media", "admin_account.created": "สร้างบัญชี Admin", "admin_account.removed": "ลบบัญชี Admin", "catalog_sync.requested": "เริ่ม Catalog sync", "catalog_sync.cancel_requested": "ขอยกเลิก Catalog sync", "catalog_sync.completed": "Catalog sync สำเร็จ", "catalog_sync.unchanged": "Catalog เป็นเวอร์ชันล่าสุด", "catalog_sync.cancelled": "ยกเลิก Catalog sync แล้ว", "catalog_sync.failed": "Catalog sync ล้มเหลว", }; export function isAuditTargetType( value: string | undefined, ): value is AuditTargetType { return AUDIT_TARGET_TYPES.some((type) => type === value); }