import { and, desc, eq, inArray, or } from "drizzle-orm"; import { isBanActive, unixNow } from "@/lib/bans"; import { AdminAuditLog, Ban, db, StaffActivities, SupportTickets, User, UsersSettings, } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; export type SanctionEvent = { id: string; at: number; kind: "ban" | "lift" | "mute" | "unmute" | "cfh" | "staff" | "status"; label: string; detail: string; actor?: string; href?: string; }; function parseAuditAt(createdAt: string): number { const asNum = Number(createdAt); if (Number.isFinite(asNum) && asNum > 1_000_000_000) return asNum; const ms = Date.parse(createdAt); return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0; } const AUDIT_ACTIONS = [ "ban", "unban", "user_mute", "user_unmute", "mod_mute", "mod_unmute", "user_ban", "ban_lift", ] as const; /** Build a merged sanction timeline for an admin user show tab. */ export async function loadUserSanctions( userId: number, ): Promise<{ events: SanctionEvent[]; muteUntil: number }> { const now = unixNow(); const events: SanctionEvent[] = []; const [bans, audits, staffActs, cfh, settingsRows] = await Promise.all([ db .select({ id: Ban.id, banReason: Ban.banReason, type: Ban.type, timestamp: Ban.timestamp, banExpire: Ban.banExpire, userStaffId: Ban.userStaffId, }) .from(Ban) .where(eq(Ban.userId, userId)) .orderBy(desc(Ban.timestamp)) .limit(40) .catch(() => []), db .select({ id: AdminAuditLog.id, action: AdminAuditLog.action, details: AdminAuditLog.details, createdAt: AdminAuditLog.createdAt, userId: AdminAuditLog.userId, actorUsername: User.username, }) .from(AdminAuditLog) .leftJoin(User, eq(AdminAuditLog.userId, User.id)) .where( and( eq(AdminAuditLog.target, "User"), eq(AdminAuditLog.targetId, userId), inArray(AdminAuditLog.action, [...AUDIT_ACTIONS]), ), ) .orderBy(desc(AdminAuditLog.id)) .limit(40) .catch(() => []), db .select({ id: StaffActivities.id, action: StaffActivities.action, description: StaffActivities.description, createdAt: StaffActivities.createdAt, userId: StaffActivities.userId, }) .from(StaffActivities) .where( and( eq(StaffActivities.targetType, "user"), eq(StaffActivities.targetId, BigInt(userId)), ), ) .orderBy(desc(StaffActivities.id)) .limit(40) .catch(() => []), db .select({ id: SupportTickets.id, issue: SupportTickets.issue, state: SupportTickets.state, timestamp: SupportTickets.timestamp, senderId: SupportTickets.senderId, reportedId: SupportTickets.reportedId, modId: SupportTickets.modId, }) .from(SupportTickets) .where( or( eq(SupportTickets.reportedId, userId), eq(SupportTickets.senderId, userId), ), ) .orderBy(desc(SupportTickets.id)) .limit(20) .catch(() => []), db .select({ muteEndTimestamp: UsersSettings.muteEndTimestamp }) .from(UsersSettings) .where(eq(UsersSettings.userId, userId)) .limit(1) .catch(() => []), ]); const settings = settingsRows[0] ?? null; const muteUntil = settings?.muteEndTimestamp ?? 0; if (muteUntil > now) { events.push({ id: "mute-active", at: now, kind: "status", label: "Mute active", detail: `Until ${formatDate(new Date(muteUntil * 1000))}`, }); } const staffIds = [ ...new Set([ ...bans.map((b) => b.userStaffId).filter((id) => id > 0), ...staffActs.map((a) => Number(a.userId)).filter((id) => id > 0), ]), ]; const staffUsers = staffIds.length > 0 ? await db .select({ id: User.id, username: User.username }) .from(User) .where(inArray(User.id, staffIds)) .catch(() => []) : []; const staffName = new Map(staffUsers.map((u) => [u.id, u.username])); for (const ban of bans) { const active = isBanActive(ban.banExpire, now); events.push({ id: `ban-${ban.id}`, at: ban.timestamp, kind: "ban", label: active ? `Ban (${ban.type}) ยท active` : `Ban (${ban.type})`, detail: ban.banReason || "โ€”", actor: staffName.get(ban.userStaffId), }); } for (const a of audits) { const kind: SanctionEvent["kind"] = a.action.includes("unmute") || a.action === "unban" ? a.action.includes("mute") ? "unmute" : "lift" : a.action.includes("mute") ? "mute" : a.action.includes("ban") ? "ban" : "staff"; events.push({ id: `audit-${a.id}`, at: parseAuditAt(a.createdAt), kind, label: a.action, detail: a.details?.slice(0, 200) || "โ€”", actor: a.actorUsername ?? undefined, }); } for (const s of staffActs) { events.push({ id: `staff-${String(s.id)}`, at: Math.floor(s.createdAt.getTime() / 1000), kind: "staff", label: s.action, detail: s.description.slice(0, 200), actor: staffName.get(Number(s.userId)), }); } for (const c of cfh) { const role = c.reportedId === userId ? "reported" : c.senderId === userId ? "reporter" : "related"; events.push({ id: `cfh-${c.id}`, at: c.timestamp, kind: "cfh", label: `CFH #${c.id} (${role}, state ${c.state})`, detail: c.issue?.slice(0, 200) || "โ€”", href: `/admin/moderation/cfh/${c.id}`, }); } events.sort((a, b) => b.at - a.at || a.id.localeCompare(b.id)); // Deduplicate near-identical ban+audit pairs within 5s const deduped: SanctionEvent[] = []; for (const ev of events) { const prev = deduped[deduped.length - 1]; if ( prev && prev.kind === ev.kind && Math.abs(prev.at - ev.at) <= 5 && prev.label.split(" ")[0] === ev.label.split(" ")[0] ) { continue; } deduped.push(ev); } return { events: deduped.slice(0, 80), muteUntil }; }