239 lines
5.6 KiB
TypeScript
239 lines
5.6 KiB
TypeScript
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 };
|
|
}
|