- Replace Prisma client runtime with Drizzle ORM (zero Prisma engine/query engine in production) - Add Prisma-compatible facade (@/lib/prisma-facade.ts) backed by Drizzle for backwards compatibility - Runtime queries route through Drizzle ORM; @prisma/client is now devDependency (types only) - Remove @prisma/adapter-mariadb dependency; delete prisma-pool.ts and types/prisma.ts - New Drizzle schema layer: src/db/schema.ts (176 tables) and src/lib/db.ts (connection) - Update README documenting the dual-layer ORM architecture - Restore src/generated/ gitignore (build artifact for local type generation) - 0 TypeScript errors, 583 tests passing The facade intentionally uses `any` types to match the Prisma Client API surface, allowing existing code to run unmodified while routing queries through Drizzle at runtime.
194 lines
5.0 KiB
TypeScript
194 lines
5.0 KiB
TypeScript
import { getTranslations } from "next-intl/server";
|
|
import {
|
|
type HelpTicketRow,
|
|
HelpTicketsTable,
|
|
} from "@/app/admin/help-tickets/help-tickets-table";
|
|
import { StatusCard } from "@/components/admin/dashboard";
|
|
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
|
import { requireModPermission } from "@/lib/admin/guard";
|
|
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
|
import { formatDate } from "@/lib/format-date";
|
|
import { PERMS } from "@/lib/permissions";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
export default async function ModHelpTicketsPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<Record<string, string>>;
|
|
}) {
|
|
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
|
|
|
|
const t = await getTranslations("pages.admin.helpTickets");
|
|
const raw = await searchParams;
|
|
const parsed = parseListParams(new URLSearchParams(raw));
|
|
const statusFilter = raw.filter_status || "open";
|
|
|
|
const conditions: any[] = [];
|
|
|
|
if (statusFilter === "open") {
|
|
conditions.push({ open: true });
|
|
} else if (statusFilter === "closed") {
|
|
conditions.push({ open: false });
|
|
}
|
|
|
|
if (parsed.search.trim()) {
|
|
const q = parsed.search.trim();
|
|
const asId = /^\d+$/.test(q) ? BigInt(q) : null;
|
|
const matchingUsers = await prisma.user
|
|
.findMany({
|
|
where: { username: { contains: q } },
|
|
select: { id: true },
|
|
take: 50,
|
|
})
|
|
.catch(() => []);
|
|
|
|
conditions.push({
|
|
OR: [
|
|
{ title: { contains: q } },
|
|
{ content: { contains: q } },
|
|
...(matchingUsers.length > 0
|
|
? [{ userId: { in: matchingUsers.map((u) => u.id) } }]
|
|
: []),
|
|
...(asId !== null ? [{ id: asId }] : []),
|
|
],
|
|
});
|
|
}
|
|
|
|
const where: any =
|
|
conditions.length === 0
|
|
? {}
|
|
: conditions.length === 1
|
|
? conditions[0]
|
|
: { AND: conditions };
|
|
|
|
const SORT_MAP: Record<
|
|
string,
|
|
any
|
|
> = {
|
|
title: { title: "asc" },
|
|
open: { open: "desc" },
|
|
user: { userId: "asc" },
|
|
date: { createdAt: "desc" },
|
|
updated: { updatedAt: "desc" },
|
|
id: { id: "desc" },
|
|
};
|
|
|
|
const baseOrder = SORT_MAP[parsed.sort ?? "updated"] ?? { updatedAt: "desc" };
|
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
|
const orderBy = {
|
|
[orderField]: parsed.order,
|
|
} as any;
|
|
|
|
const [total, openCount, closedCount, queues] = await Promise.all([
|
|
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
|
prisma.websiteHelpCenterTickets
|
|
.count({ where: { open: true } })
|
|
.catch(() => 0),
|
|
prisma.websiteHelpCenterTickets
|
|
.count({ where: { open: false } })
|
|
.catch(() => 0),
|
|
fetchTicketQueueOpenCounts(),
|
|
]);
|
|
|
|
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
|
|
|
const tickets = await prisma.websiteHelpCenterTickets
|
|
.findMany({
|
|
where,
|
|
orderBy,
|
|
skip: pagination.offset,
|
|
take: pagination.perPage,
|
|
select: {
|
|
id: true,
|
|
userId: true,
|
|
title: true,
|
|
open: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
},
|
|
})
|
|
.catch(() => []);
|
|
|
|
const ticketIds = tickets.map((ticket) => ticket.id);
|
|
const userIds = [
|
|
...new Set(
|
|
tickets
|
|
.map((ticket) => ticket.userId)
|
|
.filter((id): id is number => id != null),
|
|
),
|
|
];
|
|
|
|
const [replyGroups, users] = await Promise.all([
|
|
ticketIds.length > 0
|
|
? prisma.websiteHelpCenterTicketReplies
|
|
.groupBy({
|
|
by: ["ticketId"],
|
|
where: { ticketId: { in: ticketIds } },
|
|
_count: true,
|
|
})
|
|
.catch(() => [])
|
|
: Promise.resolve([]),
|
|
userIds.length > 0
|
|
? prisma.user
|
|
.findMany({
|
|
where: { id: { in: userIds } },
|
|
select: { id: true, username: true },
|
|
})
|
|
.catch(() => [])
|
|
: Promise.resolve([]),
|
|
]);
|
|
|
|
const replyCountByTicket = new Map(
|
|
replyGroups.map((g) => [String(g.ticketId), g._count]),
|
|
);
|
|
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
|
|
|
const rows: HelpTicketRow[] = tickets.map((ticket) => ({
|
|
id: String(ticket.id),
|
|
title: ticket.title,
|
|
user:
|
|
ticket.userId != null
|
|
? (usernameById.get(ticket.userId) ?? `#${ticket.userId}`)
|
|
: "—",
|
|
userId: ticket.userId,
|
|
replies: replyCountByTicket.get(String(ticket.id)) ?? 0,
|
|
open: ticket.open,
|
|
date: formatDate(ticket.createdAt, "date"),
|
|
updated: formatDate(ticket.updatedAt, "date"),
|
|
}));
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<TicketQueueBanner
|
|
hint={t("queueHint")}
|
|
otherHref="/mod/tickets"
|
|
otherLabel={t("queueOtherCta")}
|
|
countsLine={t("queueCounts", {
|
|
here: queues.helpOpen,
|
|
other: queues.cmsOpen,
|
|
})}
|
|
/>
|
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
|
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
|
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
|
</div>
|
|
|
|
{total === 0 && !parsed.search && statusFilter === "open" ? (
|
|
<div className="admin-empty">{t("noTickets")}</div>
|
|
) : (
|
|
<HelpTicketsTable
|
|
basePath="/mod/help-tickets"
|
|
data={{
|
|
rows,
|
|
total,
|
|
page: pagination.page,
|
|
perPage: pagination.perPage,
|
|
lastPage: pagination.lastPage,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|