Files
buzz-sheet/lib/audit-log.ts
T
gunshiz 5812d99715
CI / Verify (push) Successful in 2m30s
CI / Build immutable images and deploy (push) Successful in 3m9s
feat : comment feature
2026-10-08 01:40:08 +07:00

229 lines
6.5 KiB
TypeScript

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 = [
"comment.hidden",
"comment.restored",
"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",
"stygian.synced",
"stygian.recommendations.saved",
"stygian.activated",
] as const;
export const AUDIT_TARGET_TYPES = [
"comment",
"guide",
"extra_section",
"glossary_alias",
"media",
"admin_account",
"catalog_sync",
"stygian_schedule",
] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number];
export type AuditMetadata = Record<string, AuditLogValue>;
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<Database, "insert">;
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<void> {
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<void> {
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
> = {
comment: "Comment",
guide: "Guide",
extra_section: "Extra section",
glossary_alias: "Glossary",
media: "Media",
admin_account: "Admin account",
catalog_sync: "Catalog sync",
stygian_schedule: "Stygian schedule",
};
export const AUDIT_ACTION_LABELS: Record<AuditAction, string> = {
"comment.hidden": "ซ่อน Comment",
"comment.restored": "กู้คืน Comment",
"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 ล้มเหลว",
"stygian.synced": "Sync Stygian archive",
"stygian.recommendations.saved": "บันทึกคำแนะนำ Stygian",
"stygian.activated": "เผยแพร่ Stygian schedule",
};
export function isAuditTargetType(
value: string | undefined,
): value is AuditTargetType {
return AUDIT_TARGET_TYPES.some((type) => type === value);
}