Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
This reverts commit488b6e57c4, reversing changes made tob506b4499a.
This commit is contained in:
1 parent
488b6e57c4
commit
b1ddda66ff
802 files changed
+61370
-76659
No files matched your search
@@ -0,0 +1,140 @@
|
||||
import { asc, eq, gte } from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/mysql-core";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminTicketDetail } from "@/app/admin/tickets/[id]/admin-ticket-detail";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { db, User, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
const Creator = alias(User, "ticket_creator");
|
||||
const Assignee = alias(User, "ticket_assignee");
|
||||
const MessageUser = alias(User, "ticket_message_user");
|
||||
|
||||
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 db
|
||||
.select({
|
||||
id: WebsiteTicket.id,
|
||||
subject: WebsiteTicket.subject,
|
||||
category: WebsiteTicket.category,
|
||||
priority: WebsiteTicket.priority,
|
||||
status: WebsiteTicket.status,
|
||||
createdAt: WebsiteTicket.createdAt,
|
||||
closedAt: WebsiteTicket.closedAt,
|
||||
creatorId: Creator.id,
|
||||
creatorUsername: Creator.username,
|
||||
creatorLook: Creator.look,
|
||||
creatorRank: Creator.rank,
|
||||
creatorIpRegister: Creator.ipRegister,
|
||||
creatorMail: Creator.mail,
|
||||
creatorAccountCreated: Creator.accountCreated,
|
||||
assigneeId: Assignee.id,
|
||||
assigneeUsername: Assignee.username,
|
||||
})
|
||||
.from(WebsiteTicket)
|
||||
.innerJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
|
||||
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
|
||||
.where(eq(WebsiteTicket.id, ticketId))
|
||||
.limit(1);
|
||||
|
||||
if (!ticket) notFound();
|
||||
|
||||
const messages = await db
|
||||
.select({
|
||||
id: WebsiteTicketMessage.id,
|
||||
message: WebsiteTicketMessage.message,
|
||||
isStaff: WebsiteTicketMessage.isStaff,
|
||||
createdAt: WebsiteTicketMessage.createdAt,
|
||||
userId: MessageUser.id,
|
||||
username: MessageUser.username,
|
||||
look: MessageUser.look,
|
||||
rank: MessageUser.rank,
|
||||
})
|
||||
.from(WebsiteTicketMessage)
|
||||
.innerJoin(MessageUser, eq(WebsiteTicketMessage.userId, MessageUser.id))
|
||||
.where(eq(WebsiteTicketMessage.ticketId, ticketId))
|
||||
.orderBy(asc(WebsiteTicketMessage.createdAt));
|
||||
|
||||
const minStaffRank = await getMinStaffRank();
|
||||
const staffMembers = await db
|
||||
.select({ id: User.id, username: User.username })
|
||||
.from(User)
|
||||
.where(gte(User.rank, minStaffRank))
|
||||
.orderBy(asc(User.username));
|
||||
|
||||
const canEdit =
|
||||
canAccess(permissions, PERMS.MOD_TICKETS_EDIT, session.user.rank) ||
|
||||
canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
|
||||
|
||||
const t = await getTranslations("pages.admin.tickets");
|
||||
const queues = await fetchTicketQueueOpenCounts();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TicketQueueBanner
|
||||
hint={t("queueHint")}
|
||||
otherHref="/mod/help-tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
countsLine={t("queueCounts", {
|
||||
here: queues.cmsOpen,
|
||||
other: queues.helpOpen,
|
||||
})}
|
||||
/>
|
||||
<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.creatorId,
|
||||
username: ticket.creatorUsername,
|
||||
look: ticket.creatorLook,
|
||||
rank: ticket.creatorRank,
|
||||
mail: ticket.creatorMail ?? "",
|
||||
ipRegister: ticket.creatorIpRegister,
|
||||
accountCreated: ticket.creatorAccountCreated,
|
||||
},
|
||||
assignee:
|
||||
ticket.assigneeId != null && ticket.assigneeUsername
|
||||
? { id: ticket.assigneeId, username: ticket.assigneeUsername }
|
||||
: null,
|
||||
}}
|
||||
messages={messages.map((m) => ({
|
||||
id: m.id,
|
||||
message: m.message,
|
||||
isStaff: m.isStaff === 1,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
user: {
|
||||
id: m.userId,
|
||||
username: m.username,
|
||||
look: m.look,
|
||||
rank: m.rank,
|
||||
},
|
||||
}))}
|
||||
staffMembers={staffMembers}
|
||||
currentUserId={session.user.id}
|
||||
canEdit={canEdit}
|
||||
listHref="/mod/tickets"
|
||||
userLinkMode="public"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
like,
|
||||
ne,
|
||||
or,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/mysql-core";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
type TicketRow,
|
||||
TicketsTable,
|
||||
} from "@/app/admin/tickets/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 { db, User, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const Creator = alias(User, "ticket_creator");
|
||||
const Assignee = alias(User, "ticket_assignee");
|
||||
|
||||
const SORT_COLS = {
|
||||
subject: WebsiteTicket.subject,
|
||||
status: WebsiteTicket.status,
|
||||
priority: WebsiteTicket.priority,
|
||||
assignee: WebsiteTicket.assigneeId,
|
||||
date: WebsiteTicket.createdAt,
|
||||
id: WebsiteTicket.id,
|
||||
} as const;
|
||||
|
||||
export default async function ModTicketsDeskPage({
|
||||
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: SQL[] = [];
|
||||
|
||||
if (statusFilter === "active") {
|
||||
conditions.push(ne(WebsiteTicket.status, "closed"));
|
||||
} else if (statusFilter && statusFilter !== "all") {
|
||||
conditions.push(eq(WebsiteTicket.status, statusFilter));
|
||||
}
|
||||
|
||||
if (parsed.search.trim()) {
|
||||
const q = parsed.search.trim();
|
||||
const asId = Number(q);
|
||||
const ors: SQL[] = [
|
||||
like(WebsiteTicket.subject, `%${q}%`),
|
||||
like(WebsiteTicket.category, `%${q}%`),
|
||||
like(Creator.username, `%${q}%`),
|
||||
like(Assignee.username, `%${q}%`),
|
||||
];
|
||||
if (Number.isFinite(asId) && asId > 0) {
|
||||
ors.push(eq(WebsiteTicket.id, asId));
|
||||
}
|
||||
const searchFilter = or(...ors);
|
||||
if (searchFilter) conditions.push(searchFilter);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const sortKey =
|
||||
parsed.sort && parsed.sort in SORT_COLS
|
||||
? (parsed.sort as keyof typeof SORT_COLS)
|
||||
: "date";
|
||||
const sortCol = SORT_COLS[sortKey];
|
||||
const finalOrder =
|
||||
parsed.sort && parsed.sort in SORT_COLS
|
||||
? parsed.order === "asc"
|
||||
? asc(sortCol)
|
||||
: desc(sortCol)
|
||||
: desc(WebsiteTicket.createdAt);
|
||||
|
||||
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
|
||||
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
|
||||
.where(where)
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "open"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "in_progress"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "waiting"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "closed"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
fetchTicketQueueOpenCounts(),
|
||||
]);
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
|
||||
const tickets = await db
|
||||
.select({
|
||||
id: WebsiteTicket.id,
|
||||
subject: WebsiteTicket.subject,
|
||||
category: WebsiteTicket.category,
|
||||
status: WebsiteTicket.status,
|
||||
priority: WebsiteTicket.priority,
|
||||
creatorId: WebsiteTicket.creatorId,
|
||||
createdAt: WebsiteTicket.createdAt,
|
||||
creatorUsername: Creator.username,
|
||||
assigneeUsername: Assignee.username,
|
||||
})
|
||||
.from(WebsiteTicket)
|
||||
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
|
||||
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
|
||||
.where(where)
|
||||
.orderBy(finalOrder)
|
||||
.offset(pagination.offset)
|
||||
.limit(pagination.perPage)
|
||||
.catch(() => []);
|
||||
|
||||
const ticketIds = tickets.map((ticket) => ticket.id);
|
||||
const msgRows =
|
||||
ticketIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
ticketId: WebsiteTicketMessage.ticketId,
|
||||
total: count(),
|
||||
})
|
||||
.from(WebsiteTicketMessage)
|
||||
.where(inArray(WebsiteTicketMessage.ticketId, ticketIds))
|
||||
.groupBy(WebsiteTicketMessage.ticketId)
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const msgMap = new Map(msgRows.map((m) => [m.ticketId, m.total]));
|
||||
|
||||
const rows: TicketRow[] = tickets.map((ticket) => ({
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
creator: ticket.creatorUsername ?? "—",
|
||||
creatorId: ticket.creatorId,
|
||||
category: ticket.category,
|
||||
messages: msgMap.get(ticket.id) ?? 0,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
assignee: ticket.assigneeUsername ?? "—",
|
||||
date: formatDate(ticket.createdAt, "date"),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TicketQueueBanner
|
||||
hint={t("queueHint")}
|
||||
otherHref="/mod/help-tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
countsLine={t("queueCounts", {
|
||||
here: queues.cmsOpen,
|
||||
other: queues.helpOpen,
|
||||
})}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { UnifiedTicketsTable } from "@/components/admin/unified-tickets-table";
|
||||
import Link from "@/components/link";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
fetchUnifiedTicketInbox,
|
||||
type TicketInboxTypeFilter,
|
||||
} from "@/lib/admin/ticket-inbox";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { parseListParams } from "@/lib/admin-helpers";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function ModTicketInboxPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
|
||||
|
||||
const t = await getTranslations("pages.admin.ticketInbox");
|
||||
const raw = await searchParams;
|
||||
const parsed = parseListParams(new URLSearchParams(raw));
|
||||
const typeRaw = raw.filter_type || "all";
|
||||
const type: TicketInboxTypeFilter =
|
||||
typeRaw === "cms" || typeRaw === "help" ? typeRaw : "all";
|
||||
const statusFilter = raw.filter_status || "open";
|
||||
const openOnly = statusFilter !== "all";
|
||||
|
||||
const [inbox, queues] = await Promise.all([
|
||||
fetchUnifiedTicketInbox({
|
||||
page: parsed.page,
|
||||
perPage: parsed.perPage,
|
||||
search: parsed.search,
|
||||
type,
|
||||
openOnly,
|
||||
base: "mod",
|
||||
}),
|
||||
fetchTicketQueueOpenCounts(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-semibold text-[var(--admin-text)]">
|
||||
{t("modNavLabel")}
|
||||
</span>
|
||||
<Link
|
||||
href="/mod/tickets"
|
||||
className="rounded-md px-2 py-1 no-underline text-[var(--admin-accent)] hover:underline"
|
||||
>
|
||||
{t("modNavInbox")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/mod/tickets/desk"
|
||||
className="rounded-md px-2 py-1 no-underline text-[var(--admin-text-muted)] hover:text-[var(--admin-text)]"
|
||||
>
|
||||
{t("modNavDesk")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/mod/help-tickets"
|
||||
className="rounded-md px-2 py-1 no-underline text-[var(--admin-text-muted)] hover:text-[var(--admin-text)]"
|
||||
>
|
||||
{t("modNavHelp")}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="m-0 text-sm text-[var(--admin-text-muted)]">{t("hint")}</p>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||
<StatusCard label={t("statCmsOpen")} value={queues.cmsOpen} icon="🎫" />
|
||||
<StatusCard
|
||||
label={t("statHelpOpen")}
|
||||
value={queues.helpOpen}
|
||||
state={queues.helpOpen > 0 ? "warn" : "neutral"}
|
||||
icon="🛟"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("statTotal")}
|
||||
value={queues.cmsOpen + queues.helpOpen}
|
||||
icon="📥"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inbox.total === 0 && !parsed.search ? (
|
||||
<div className="admin-empty">{t("noTickets")}</div>
|
||||
) : (
|
||||
<UnifiedTicketsTable
|
||||
data={{
|
||||
rows: inbox.rows,
|
||||
total: inbox.total,
|
||||
page: inbox.page,
|
||||
perPage: inbox.perPage,
|
||||
lastPage: inbox.lastPage,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user