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

+111 -118
View File
@@ -4,44 +4,10 @@ import { getTranslations } from "next-intl/server";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { getAuditLogs } from "@/lib/services/audit";
import {
AuditSection,
ExportSection,
ImportSection,
ManageSection,
PresetsSection,
TestSection,
} from "./client";
import { AuditSection, ExportSection } from "./client";
export const dynamic = "force-dynamic";
const GROUPS = [
"Core",
"Users",
"Rooms",
"Bans",
"News",
"Logs",
"Settings",
"Wordfilter",
"Catalog",
"Shop",
"Events",
"Tickets",
"Moderation",
"Polls",
"Analytics",
"DevOps",
"Prefixes",
"Banners",
"Radio",
"RCON",
"Assets",
"Export",
"Notifications",
"CMS Pages",
];
export default async function AdminHousekeeping({
searchParams,
}: {
@@ -53,7 +19,8 @@ export default async function AdminHousekeeping({
| "ranks"
| "presets"
| "test"
| "audit";
| "audit"
| "compare";
group?: string;
}>;
}) {
@@ -64,7 +31,10 @@ export default async function AdminHousekeeping({
const t = await getTranslations("pages.admin.housekeeping");
const sp = await searchParams;
const tab = sp.tab ?? "overview";
let tab = sp.tab ?? "overview";
if (tab === "manage" || tab === "presets" || tab === "test") {
tab = "compare";
}
type HKPerm = Awaited<
ReturnType<typeof prisma.websiteHousekeepingPermissions.findMany>
@@ -89,15 +59,48 @@ export default async function AdminHousekeeping({
rankGroups[p.minRank].push(p);
}
const missingDeps: Array<{ permission: string; dependsOn: string }> = [];
const permSet = new Set(allPermissions.map((p) => p.permission));
for (const p of allPermissions) {
if (p.dependsOn && !permSet.has(p.dependsOn)) {
missingDeps.push({ permission: p.permission, dependsOn: p.dependsOn });
}
const aclSlugs = await prisma.aclPermission
.findMany({ select: { slug: true } })
.catch(() => [] as { slug: string }[]);
const aclSlugSet = new Set(aclSlugs.map((p) => p.slug));
const aclRolePerms = await prisma.aclModelPermission
.findMany({
where: { modelType: "Role" },
include: {
permission: { select: { slug: true } },
role: { select: { slug: true } },
},
})
.catch(() => []);
const aclMinRankBySlug = new Map<string, number>();
for (const row of aclRolePerms) {
const roleSlug = row.role.slug;
if (!roleSlug.startsWith("rank_")) continue;
const rank = Number(roleSlug.slice(5));
if (!Number.isFinite(rank)) continue;
const slug = row.permission.slug;
const prev = aclMinRankBySlug.get(slug);
if (prev === undefined || rank < prev) aclMinRankBySlug.set(slug, rank);
}
const allGroups = [...new Set([...GROUPS, ...Object.keys(groupCounts)])];
const compareRows = allPermissions.map((p) => {
const inAcl = aclSlugSet.has(p.permission);
const aclMin = aclMinRankBySlug.get(p.permission);
return {
permission: p.permission,
hkMinRank: p.minRank,
inAcl,
aclMinRank: aclMin ?? null,
aligned: inAcl && aclMin !== undefined ? aclMin === p.minRank : false,
};
});
const missingInAcl = compareRows.filter((r) => !r.inAcl).length;
const rankMismatch = compareRows.filter(
(r) => r.inAcl && r.aclMinRank !== null && r.aclMinRank !== r.hkMinRank,
).length;
const alignedCount = compareRows.filter((r) => r.aligned).length;
const auditLogs = await getAuditLogs({
search: "housekeeping",
@@ -120,6 +123,9 @@ export default async function AdminHousekeeping({
{t("legacyBannerCta")}
</Link>
</p>
<p className="m-0 mt-2 text-xs text-[var(--admin-text-muted)]">
{t("writesRetired")}
</p>
</div>
<div className="admin-tabs mb-4">
@@ -130,16 +136,10 @@ export default async function AdminHousekeeping({
{t("tabOverview")}
</Link>
<Link
href="/admin/housekeeping?tab=manage"
className={`admin-tab ${tab === "manage" ? "active" : ""}`}
href="/admin/housekeeping?tab=compare"
className={`admin-tab ${tab === "compare" ? "active" : ""}`}
>
{t("tabManage")}
</Link>
<Link
href="/admin/housekeeping?tab=import"
className={`admin-tab ${tab === "import" ? "active" : ""}`}
>
{t("tabImport")}
{t("tabCompare")}
</Link>
<Link
href="/admin/housekeeping?tab=ranks"
@@ -148,16 +148,10 @@ export default async function AdminHousekeeping({
{t("tabRanks")}
</Link>
<Link
href="/admin/housekeeping?tab=presets"
className={`admin-tab ${tab === "presets" ? "active" : ""}`}
href="/admin/housekeeping?tab=import"
className={`admin-tab ${tab === "import" ? "active" : ""}`}
>
{t("tabPresets")}
</Link>
<Link
href="/admin/housekeeping?tab=test"
className={`admin-tab ${tab === "test" ? "active" : ""}`}
>
{t("tabTest")}
{t("tabExportOnly")}
</Link>
<Link
href="/admin/housekeeping?tab=audit"
@@ -183,16 +177,14 @@ export default async function AdminHousekeeping({
{t("groupCount", { count: Object.keys(groupCounts).length })}
</span>
<span className="admin-stat-chip">
{t("rankCount", { count: Object.keys(rankGroups).length })}
{t("compareAligned", { count: alignedCount })}
</span>
<span className="admin-stat-chip">
{t("compareMissingAcl", { count: missingInAcl })}
</span>
<span className="admin-stat-chip">
{t("compareMismatch", { count: rankMismatch })}
</span>
{missingDeps.length > 0 && (
<span
className="admin-stat-chip"
style={{ color: "var(--color-danger)" }}
>
{t("missingDeps", { count: missingDeps.length })}
</span>
)}
</div>
<section className="mb-6">
@@ -201,19 +193,12 @@ export default async function AdminHousekeeping({
{Object.entries(groupCounts)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, count]) => (
<Link
key={group}
href={
"/admin/housekeeping?tab=manage&group=" +
encodeURIComponent(group)
}
className="admin-card block p-3 hover:opacity-80 transition-opacity"
>
<div key={group} className="admin-card p-3">
<div className="font-semibold text-sm">{group}</div>
<div className="text-xs opacity-60">
{count} {count === 1 ? t("permission") : t("permissions")}
</div>
</Link>
</div>
))}
</div>
</section>
@@ -245,52 +230,60 @@ export default async function AdminHousekeeping({
</table>
</div>
</section>
{missingDeps.length > 0 && (
<section className="mt-6">
<h2
className="admin-section-title"
style={{ color: "var(--color-danger)" }}
>
{t("missingDeps")}
</h2>
<div className="admin-card">
<ul className="list-disc pl-5 text-sm">
{missingDeps.map((d) => (
<li key={d.permission}>
<strong>{d.permission}</strong> {t("dependsOn")}{" "}
<code>{d.dependsOn}</code> &mdash; {t("notFound")}
</li>
))}
</ul>
</div>
</section>
)}
</>
)}
{tab === "manage" && (
<ManageSection
permissions={allPermissions}
groups={allGroups}
groupCounts={groupCounts}
permSet={permSet}
/>
{tab === "compare" && (
<section>
<h2 className="admin-section-title">{t("tabCompare")}</h2>
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
{t("compareHint")}
</p>
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colPermission")}</th>
<th>{t("colMinRank")}</th>
<th>{t("colAclPresent")}</th>
<th>{t("colAclMinRank")}</th>
<th>{t("colStatus")}</th>
</tr>
</thead>
<tbody>
{compareRows.map((row) => {
const status = !row.inAcl
? t("statusMissingAcl")
: row.aligned
? t("statusAligned")
: t("statusMismatch");
return (
<tr key={row.permission}>
<td>
<code className="text-xs">{row.permission}</code>
</td>
<td>{row.hkMinRank}</td>
<td>{row.inAcl ? "✓" : "—"}</td>
<td>{row.aclMinRank ?? "—"}</td>
<td className="text-xs">{status}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
{tab === "import" && (
<>
<ImportSection />
<div className="mt-8">
<ExportSection />
</div>
</>
<div className="mt-2">
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
{t("exportOnlyHint")}
</p>
<ExportSection />
</div>
)}
{tab === "presets" && <PresetsSection />}
{tab === "test" && <TestSection permissions={allPermissions} />}
{tab === "ranks" && (
<section>
<h2 className="admin-section-title">{t("perRank")}</h2>
+14 -5
View File
@@ -5,16 +5,20 @@ import { prisma } from "@/lib/prisma";
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
import { PermissionsList } from "./permissions-list";
export default async function PermissionsPage(_props: {
export default async function PermissionsPage({
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams?: Promise<{ from?: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (
!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)
) {
if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank)) {
redirect("/admin");
}
const sp = (await searchParams) ?? {};
const fromHousekeeping = sp.from === "housekeeping";
const ranks = await fetchEmulatorRankSummaries(prisma);
// User counts per rank
@@ -31,5 +35,10 @@ export default async function PermissionsPage(_props: {
userCount: countMap[rank.id] ?? 0,
}));
return <PermissionsList ranks={ranksWithCounts} />;
return (
<PermissionsList
ranks={ranksWithCounts}
fromHousekeeping={fromHousekeeping}
/>
);
}
+16 -3
View File
@@ -42,6 +42,7 @@ interface RankSummary {
interface Props {
ranks: RankSummary[];
fromHousekeeping?: boolean;
}
const RANK_ICONS: Record<number, React.ReactNode> = {
@@ -58,7 +59,10 @@ const RANK_COLORS: Record<number, string> = {
4: "border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]",
};
export function PermissionsList({ ranks: initialRanks }: Props) {
export function PermissionsList({
ranks: initialRanks,
fromHousekeeping = false,
}: Props) {
const { run, isPending } = useServerAction();
const [ranks, setRanks] = useState(initialRanks);
const [deleteTarget, setDeleteTarget] = useState<RankSummary | null>(null);
@@ -98,14 +102,23 @@ export function PermissionsList({ ranks: initialRanks }: Props) {
return (
<div className="space-y-6">
{fromHousekeeping ? (
<div
className="rounded-lg border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-4 py-3 text-sm"
role="status"
>
Housekeeping writes are retired. Edit live ACL ranks and permissions
here.
</div>
) : null}
<div
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm text-[var(--admin-text)]"
role="status"
>
<p className="m-0 font-semibold">Live ACL</p>
<p className="m-0 mt-1 text-muted-foreground">
Rank ACL here controls real admin access. The Housekeeping table is
legacy reference only and does not gate routes.
Rank ACL here controls real admin and mod access. The Housekeeping
table is a read-only legacy archive with an ACL compare view.
</p>
</div>
@@ -85,18 +85,29 @@ export function AdminTicketDetail({
staffMembers,
currentUserId,
canEdit,
listHref = "/admin/tickets",
userLinkMode = "admin",
}: {
ticket: TicketInfo;
messages: TicketMessage[];
staffMembers: StaffMember[];
currentUserId: number;
canEdit: boolean;
listHref?: string;
userLinkMode?: "admin" | "public";
}) {
const [messages] = useState(initialMessages);
const [reply, setReply] = useState("");
const { run, isPending } = useServerAction();
const messagesEndRef = useRef<HTMLDivElement>(null);
function creatorHref() {
if (userLinkMode === "public") {
return `/u/${encodeURIComponent(ticket.creator.username)}`;
}
return `/admin/users/show/${ticket.creator.id}`;
}
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -152,7 +163,7 @@ export function AdminTicketDetail({
<div className="space-y-6">
{/* Header */}
<div className="flex items-start gap-4">
<Link href="/admin/tickets">
<Link href={listHref}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
@@ -382,7 +393,7 @@ export function AdminTicketDetail({
</CardHeader>
<CardContent className="space-y-2">
<Link
href={`/admin/users/show/${ticket.creator.id}`}
href={creatorHref()}
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
>
<User className="h-3.5 w-3.5" />
+8 -2
View File
@@ -29,7 +29,13 @@ const STATUS_VARIANT: Record<
closed: "secondary",
};
export function TicketsTable({ data }: { data: PaginatedResult<TicketRow> }) {
export function TicketsTable({
data,
basePath = "/admin/tickets",
}: {
data: PaginatedResult<TicketRow>;
basePath?: string;
}) {
const t = useTranslations("pages.admin.tickets");
const statusLabel: Record<string, string> = {
@@ -51,7 +57,7 @@ export function TicketsTable({ data }: { data: PaginatedResult<TicketRow> }) {
#{row.id}
</span>
<Link
href={`/admin/tickets/${row.id}`}
href={`${basePath}/${row.id}`}
className="font-medium truncate hover:underline"
>
{row.subject}
+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>
);
}