feat(mod): tickets+team tabs; retire HK writes for live ACL
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 30s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-22 21:35:35 +02:00
1 parent 084cca6ea4
commit 75cace41ea
16 files changed
+735 -1002

No files matched your search

+14 -1
View File
@@ -1,4 +1,4 @@
import { Ban, Gavel, LogOut, Radio, Shield } from "lucide-react";
import { Ban, Gavel, LogOut, Radio, Shield, Ticket, Users } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
@@ -42,11 +42,23 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
const showBans =
canAccess(permissions, PERMS.MOD_BANS_VIEW, staff.rank) ||
canAccess(permissions, PERMS.BANS_VIEW, staff.rank);
const showTickets =
canAccess(permissions, PERMS.MOD_TICKETS_VIEW, staff.rank) ||
canAccess(permissions, PERMS.TICKETS_VIEW, staff.rank);
const showTeam =
canAccess(permissions, PERMS.MOD_TEAM_VIEW, staff.rank) ||
canAccess(permissions, PERMS.MODERATION_VIEW, staff.rank);
const showAdmin = canAccess(permissions, PERMS.ADMIN_DASHBOARD, staff.rank);
const nav = [
{ href: "/mod", label: t("nav.overview"), icon: Shield, show: true },
{ href: "/mod/cfh", label: t("nav.cfh"), icon: Radio, show: showCfh },
{
href: "/mod/tickets",
label: t("nav.tickets"),
icon: Ticket,
show: showTickets,
},
{
href: "/mod/actions",
label: t("nav.actions"),
@@ -54,6 +66,7 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
show: showActions,
},
{ href: "/mod/bans", label: t("nav.bans"), icon: Ban, show: showBans },
{ href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
].filter((item) => item.show);
return (
+49 -3
View File
@@ -1,4 +1,4 @@
import { Ban, Gavel, Radio, Shield } from "lucide-react";
import { Ban, Gavel, Radio, Shield, Ticket, Users } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { StatsCard } from "@/components/admin/stats-card";
@@ -23,8 +23,14 @@ export default async function ModDashboardPage() {
const showBans =
canAccess(permissions, PERMS.MOD_BANS_VIEW, rank) ||
canAccess(permissions, PERMS.BANS_VIEW, rank);
const showTickets =
canAccess(permissions, PERMS.MOD_TICKETS_VIEW, rank) ||
canAccess(permissions, PERMS.TICKETS_VIEW, rank);
const showTeam =
canAccess(permissions, PERMS.MOD_TEAM_VIEW, rank) ||
canAccess(permissions, PERMS.MODERATION_VIEW, rank);
const [openCfh, todayBans, modsOnline] = await Promise.all([
const [openCfh, todayBans, modsOnline, openTickets] = await Promise.all([
showCfh
? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0)
: Promise.resolve(0),
@@ -36,6 +42,11 @@ export default async function ModDashboardPage() {
prisma.user
.count({ where: { online: "1", rank: { gte: minStaffRank } } })
.catch(() => 0),
showTickets
? prisma.websiteTicket
.count({ where: { status: { not: "closed" } } })
.catch(() => 0)
: Promise.resolve(0),
]);
return (
@@ -47,7 +58,7 @@ export default async function ModDashboardPage() {
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{showCfh ? (
<StatsCard
title={t("stats.openCfh")}
@@ -56,6 +67,14 @@ export default async function ModDashboardPage() {
href="/mod/cfh"
/>
) : null}
{showTickets ? (
<StatsCard
title={t("stats.openTickets")}
value={openTickets}
icon={<Ticket className="h-4 w-4" />}
href="/mod/tickets"
/>
) : null}
{showBans ? (
<StatsCard
title={t("stats.bansToday")}
@@ -68,6 +87,7 @@ export default async function ModDashboardPage() {
title={t("stats.modsOnline")}
value={modsOnline}
icon={<Shield className="h-4 w-4" />}
href={showTeam ? "/mod/team" : undefined}
/>
{showActions ? (
<StatsCard
@@ -78,6 +98,15 @@ export default async function ModDashboardPage() {
href="/mod/actions"
/>
) : null}
{showTeam ? (
<StatsCard
title={t("stats.team")}
value="→"
description={t("stats.teamHint")}
icon={<Users className="h-4 w-4" />}
href="/mod/team"
/>
) : null}
</div>
<section className="admin-card space-y-2 p-4">
@@ -90,6 +119,16 @@ export default async function ModDashboardPage() {
</Link>
</li>
) : null}
{showTickets ? (
<li>
<Link
href="/mod/tickets"
className="text-primary hover:underline"
>
{t("nav.tickets")}
</Link>
</li>
) : null}
{showActions ? (
<li>
<Link
@@ -107,6 +146,13 @@ export default async function ModDashboardPage() {
</Link>
</li>
) : null}
{showTeam ? (
<li>
<Link href="/mod/team" className="text-primary hover:underline">
{t("nav.team")}
</Link>
</li>
) : null}
</ul>
</section>
</div>
+149
View File
@@ -0,0 +1,149 @@
import Link from "next/link";
import { AvatarImage } from "@/components/shared/avatar-image";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { requireModPermission } from "@/lib/admin/guard";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export default async function ModTeamLitePage() {
await requireModPermission([PERMS.MOD_TEAM_VIEW, PERMS.MODERATION_VIEW]);
const minStaffRank = await getMinStaffRank();
const staff = await prisma.user.findMany({
where: { rank: { gte: minStaffRank } },
select: {
id: true,
username: true,
look: true,
rank: true,
online: true,
lastLogin: true,
lastOnline: true,
},
orderBy: [{ online: "desc" }, { rank: "desc" }],
});
const [cfhCounts, webTicketCounts, modActionCounts] = await Promise.all([
prisma.supportTickets.groupBy({
by: ["modId"],
where: { modId: { in: staff.map((s) => s.id) } },
_count: { _all: true },
}),
prisma.websiteTicket.groupBy({
by: ["assigneeId"],
where: { assigneeId: { in: staff.map((s) => s.id) } },
_count: { _all: true },
}),
prisma.adminAuditLog.groupBy({
by: ["userId"],
where: {
userId: { in: staff.map((s) => s.id) },
action: { startsWith: "mod_" },
},
_count: { _all: true },
}),
]);
const cfhMap = new Map(cfhCounts.map((c) => [c.modId, c._count._all]));
const webMap = new Map(
webTicketCounts.map((c) => [c.assigneeId, c._count._all]),
);
const actionMap = new Map(
modActionCounts.map((c) => [c.userId, c._count._all]),
);
return (
<div className="space-y-6">
<div className="grid gap-4 grid-cols-3">
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-[var(--admin-success)]">
{staff.filter((s) => s.online === "1").length}
</p>
<p className="text-xs text-muted-foreground">Online Now</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold">{staff.length}</p>
<p className="text-xs text-muted-foreground">Total Staff</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-primary">
{staff.filter((s) => s.rank > minStaffRank).length}
</p>
<p className="text-xs text-muted-foreground">Above min rank</p>
</CardContent>
</Card>
</div>
<div className="rounded-lg border">
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-4 p-3 bg-muted/50 text-xs font-medium text-muted-foreground border-b">
<span />
<span>Name</span>
<span>Rank</span>
<span>CFH</span>
<span>Tickets</span>
<span>Actions</span>
<span>Last Seen</span>
</div>
{staff.map((member) => (
<Link
key={member.id}
href={`/u/${encodeURIComponent(member.username)}`}
className="grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-4 p-3 items-center hover:bg-accent/50 transition-all duration-150 border-b last:border-0"
>
<div className="relative">
<AvatarImage
figure={member.look}
options={{ headOnly: true, size: "l" }}
alt={member.username}
width={32}
height={32}
/>
<span
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-background ${
member.online === "1"
? "bg-[var(--admin-success)]"
: "bg-[var(--admin-text-muted)]"
}`}
/>
</div>
<div>
<p className="text-sm font-medium">{member.username}</p>
</div>
<Badge variant="outline" className="text-[10px]">
Rank {member.rank}
</Badge>
<div className="text-center">
<p className="text-sm font-bold">{cfhMap.get(member.id) ?? 0}</p>
<p className="text-[9px] text-muted-foreground">CFH</p>
</div>
<div className="text-center">
<p className="text-sm font-bold">{webMap.get(member.id) ?? 0}</p>
<p className="text-[9px] text-muted-foreground">Web</p>
</div>
<div className="text-center">
<p className="text-sm font-bold">
{actionMap.get(member.id) ?? 0}
</p>
<p className="text-[9px] text-muted-foreground">Mod</p>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{member.online === "1"
? "Online"
: member.lastOnline
? new Date(member.lastOnline * 1000).toLocaleDateString()
: "—"}
</span>
</Link>
))}
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { notFound } from "next/navigation";
import { AdminTicketDetail } from "@/app/admin/tickets/[id]/admin-ticket-detail";
import { requireModPermission } from "@/lib/admin/guard";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export default async function ModTicketDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
const { session, permissions } = await getAdminContext();
const { id } = await params;
const ticketId = Number(id);
if (Number.isNaN(ticketId)) notFound();
const ticket = await prisma.websiteTicket.findUnique({
where: { id: ticketId },
include: {
creator: {
select: {
id: true,
username: true,
look: true,
rank: true,
ipRegister: true,
mail: true,
accountCreated: true,
},
},
assignee: { select: { id: true, username: true } },
messages: {
orderBy: { createdAt: "asc" },
include: {
user: {
select: { id: true, username: true, look: true, rank: true },
},
},
},
},
});
if (!ticket) notFound();
const minStaffRank = await getMinStaffRank();
const staffMembers = await prisma.user.findMany({
where: { rank: { gte: minStaffRank } },
select: { id: true, username: true },
orderBy: { username: "asc" },
});
const canEdit =
canAccess(permissions, PERMS.MOD_TICKETS_EDIT, session.user.rank) ||
canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
return (
<AdminTicketDetail
ticket={{
id: ticket.id,
subject: ticket.subject,
category: ticket.category,
priority: ticket.priority,
status: ticket.status,
createdAt: ticket.createdAt.toISOString(),
closedAt: ticket.closedAt?.toISOString() ?? null,
creator: {
id: ticket.creator.id,
username: ticket.creator.username,
look: ticket.creator.look,
rank: ticket.creator.rank,
mail: ticket.creator.mail ?? "",
ipRegister: ticket.creator.ipRegister,
accountCreated: ticket.creator.accountCreated,
},
assignee: ticket.assignee
? { id: ticket.assignee.id, username: ticket.assignee.username }
: null,
}}
messages={ticket.messages.map((m) => ({
id: m.id,
message: m.message,
isStaff: m.isStaff === 1,
createdAt: m.createdAt.toISOString(),
user: {
id: m.user.id,
username: m.user.username,
look: m.user.look,
rank: m.user.rank,
},
}))}
staffMembers={staffMembers}
currentUserId={session.user.id}
canEdit={canEdit}
listHref="/mod/tickets"
userLinkMode="public"
/>
);
}
+155
View File
@@ -0,0 +1,155 @@
import { getTranslations } from "next-intl/server";
import {
type TicketRow,
TicketsTable,
} from "@/app/admin/tickets/tickets-table";
import { StatusCard } from "@/components/admin/dashboard";
import type { Prisma } from "@/generated/prisma/client";
import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 10) : "—";
}
export default async function ModTicketsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
const t = await getTranslations("pages.admin.tickets");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const statusFilter = raw.filter_status || raw.status || "active";
const conditions: Prisma.WebsiteTicketWhereInput[] = [];
if (statusFilter === "active") {
conditions.push({ status: { not: "closed" } });
} else if (statusFilter && statusFilter !== "all") {
conditions.push({ status: statusFilter });
}
if (parsed.search.trim()) {
const q = parsed.search.trim();
const asId = Number(q);
conditions.push({
OR: [
{ subject: { contains: q } },
{ category: { contains: q } },
{ creator: { username: { contains: q } } },
{ assignee: { username: { contains: q } } },
...(Number.isFinite(asId) && asId > 0 ? [{ id: asId }] : []),
],
});
}
const where: Prisma.WebsiteTicketWhereInput =
conditions.length === 0
? {}
: conditions.length === 1
? conditions[0]
: { AND: conditions };
const SORT_MAP: Record<string, Prisma.WebsiteTicketOrderByWithRelationInput> =
{
subject: { subject: "asc" },
status: { status: "asc" },
priority: { priority: "asc" },
assignee: { assigneeId: "asc" },
date: { createdAt: "desc" },
id: { id: "desc" },
};
const baseOrder = SORT_MAP[parsed.sort ?? "date"] ?? { createdAt: "desc" };
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
const orderBy = {
[orderField]: parsed.order,
} as Prisma.WebsiteTicketOrderByWithRelationInput;
const [total, openCount, inProgressCount, waitingCount, closedCount] =
await Promise.all([
prisma.websiteTicket.count({ where }).catch(() => 0),
prisma.websiteTicket.count({ where: { status: "open" } }).catch(() => 0),
prisma.websiteTicket
.count({ where: { status: "in_progress" } })
.catch(() => 0),
prisma.websiteTicket
.count({ where: { status: "waiting" } })
.catch(() => 0),
prisma.websiteTicket
.count({ where: { status: "closed" } })
.catch(() => 0),
]);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const tickets = await prisma.websiteTicket
.findMany({
where,
orderBy,
skip: pagination.offset,
take: pagination.perPage,
include: {
creator: { select: { id: true, username: true } },
assignee: { select: { id: true, username: true } },
_count: { select: { messages: true } },
},
})
.catch(() => []);
const rows: TicketRow[] = tickets.map((ticket) => ({
id: ticket.id,
subject: ticket.subject,
creator: ticket.creator.username,
creatorId: ticket.creatorId,
category: ticket.category,
messages: ticket._count.messages,
status: ticket.status,
priority: ticket.priority,
assignee: ticket.assignee?.username ?? "—",
date: fromDate(ticket.createdAt),
}));
return (
<div className="space-y-6">
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
<StatusCard
label={t("statusInProgress")}
value={inProgressCount}
state={inProgressCount > 0 ? "warn" : "neutral"}
icon="⏳"
/>
<StatusCard
label={t("statusWaiting")}
value={waitingCount}
state={waitingCount > 0 ? "warn" : "neutral"}
icon="🕒"
/>
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
</div>
{total === 0 && !parsed.search && statusFilter === "active" ? (
<div className="admin-empty">{t("noTickets")}</div>
) : (
<TicketsTable
basePath="/mod/tickets"
data={{
rows,
total,
page: pagination.page,
perPage: pagination.perPage,
lastPage: pagination.lastPage,
}}
/>
)}
</div>
);
}