feat(admin): guilds console + user sanctions timeline
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
1e77398a7c
commit
a384c9a8bb
10 files changed
+3861
-2991
No files matched your search
@@ -0,0 +1,224 @@
|
||||
import { isBanActive, unixNow } from "@/lib/bans";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
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, settings] = await Promise.all([
|
||||
prisma.ban
|
||||
.findMany({
|
||||
where: { userId },
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: 40,
|
||||
select: {
|
||||
id: true,
|
||||
banReason: true,
|
||||
type: true,
|
||||
timestamp: true,
|
||||
banExpire: true,
|
||||
userStaffId: true,
|
||||
},
|
||||
})
|
||||
.catch(() => []),
|
||||
prisma.adminAuditLog
|
||||
.findMany({
|
||||
where: {
|
||||
target: "User",
|
||||
targetId: userId,
|
||||
action: { in: [...AUDIT_ACTIONS] },
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
take: 40,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
details: true,
|
||||
createdAt: true,
|
||||
userId: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => []),
|
||||
prisma.staffActivities
|
||||
.findMany({
|
||||
where: {
|
||||
targetType: "user",
|
||||
targetId: BigInt(userId),
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
take: 40,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
userId: true,
|
||||
},
|
||||
})
|
||||
.catch(() => []),
|
||||
prisma.supportTickets
|
||||
.findMany({
|
||||
where: {
|
||||
OR: [{ reportedId: userId }, { senderId: userId }],
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
issue: true,
|
||||
state: true,
|
||||
timestamp: true,
|
||||
senderId: true,
|
||||
reportedId: true,
|
||||
modId: true,
|
||||
},
|
||||
})
|
||||
.catch(() => []),
|
||||
prisma.usersSettings
|
||||
.findUnique({
|
||||
where: { userId },
|
||||
select: { muteEndTimestamp: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const muteUntil = settings?.muteEndTimestamp ?? 0;
|
||||
if (muteUntil > now) {
|
||||
events.push({
|
||||
id: "mute-active",
|
||||
at: now,
|
||||
kind: "status",
|
||||
label: "Mute active",
|
||||
detail: `Until ${new Date(muteUntil * 1000).toISOString().slice(0, 16).replace("T", " ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
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 prisma.user
|
||||
.findMany({
|
||||
where: { id: { in: staffIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
.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.user?.username,
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user