From 25d0a1305090c2aa5cf62fb37cfe9de3e4bb0e95 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 11 Sep 2026 00:20:54 +0200 Subject: [PATCH] feat(support): filter waiting tickets and staff assignments --- src/app/admin/tickets/desk/page.tsx | 26 ++++++++++++-- src/app/admin/tickets/tickets-table.tsx | 47 ++++++++++++++++++++++++- src/app/mod/tickets/desk/page.tsx | 28 +++++++++++++-- src/lib/admin/ticket-desk-query.test.ts | 30 ++++++++++++++++ src/lib/admin/ticket-desk-query.ts | 20 +++++++++++ src/lib/admin/ticket-reply-age.test.ts | 18 ++++++++++ src/lib/admin/ticket-reply-age.ts | 10 ++++++ 7 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 src/lib/admin/ticket-desk-query.test.ts create mode 100644 src/lib/admin/ticket-desk-query.ts create mode 100644 src/lib/admin/ticket-reply-age.test.ts create mode 100644 src/lib/admin/ticket-reply-age.ts diff --git a/src/app/admin/tickets/desk/page.tsx b/src/app/admin/tickets/desk/page.tsx index d2dd6471..e5e322b4 100644 --- a/src/app/admin/tickets/desk/page.tsx +++ b/src/app/admin/tickets/desk/page.tsx @@ -15,7 +15,14 @@ import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner"; +import { + awaitingStaffCondition, + latestTicketReplyAt, + latestTicketReplyIsStaff, + ticketAssignmentCondition, +} from "@/lib/admin/ticket-desk-query"; import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts"; +import { ticketReplyAge } from "@/lib/admin/ticket-reply-age"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { db, User, WebsiteTicket } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; @@ -41,8 +48,15 @@ export default async function AdminTicketsDeskPage({ const Assignee = alias(User, "ticket_assignee"); const conditions: SQL[] = []; + const assignment = ticketAssignmentCondition( + raw.filter_assignee, + session.user.id, + ); + if (assignment) conditions.push(assignment); - if (statusFilter === "active") { + if (statusFilter === "awaiting_staff") { + if (awaitingStaffCondition) conditions.push(awaitingStaffCondition); + } else if (statusFilter === "active") { conditions.push(ne(WebsiteTicket.status, "closed")); } else if (statusFilter && statusFilter !== "all") { conditions.push(eq(WebsiteTicket.status, statusFilter)); @@ -130,6 +144,8 @@ export default async function AdminTicketsDeskPage({ createdAt: WebsiteTicket.createdAt, creatorUsername: Creator.username, assigneeUsername: Assignee.username, + replyAt: latestTicketReplyAt, + replyIsStaff: latestTicketReplyIsStaff, messageCount: sql`( SELECT COUNT(*) FROM website_ticket_messages m WHERE m.ticket_id = ${WebsiteTicket.id} @@ -152,6 +168,9 @@ export default async function AdminTicketsDeskPage({ category: ticket.category, messages: Number(ticket.messageCount ?? 0), status: ticket.status, + awaitingStaff: + ticket.status !== "closed" && Number(ticket.replyIsStaff) === 0, + replyAgeMinutes: ticketReplyAge(ticket.replyAt), priority: ticket.priority, assignee: ticket.assigneeUsername ?? "—", date: formatDate(ticket.createdAt, "date"), @@ -185,7 +204,10 @@ export default async function AdminTicketsDeskPage({ - {total === 0 && !parsed.search && statusFilter === "active" ? ( + {total === 0 && + !parsed.search && + statusFilter === "active" && + !raw.filter_assignee ? (
{t("noTickets")}
) : ( {p}; }, }, - { key: "assignee", label: t("colAssigned"), sortable: true }, + { + key: "assignee", + label: t("colAssigned"), + sortable: true, + filterKey: "filter_assignee", + filterOptions: [ + { label: t("assignedMine"), value: "mine" }, + { label: t("unassigned"), value: "unassigned" }, + { label: t("assignedAny"), value: "assigned" }, + ], + }, + { + key: "replyAgeMinutes", + label: t("lastReplyAge"), + render: (_value, row) => ( +
+ {row.awaitingStaff && ( + {t("awaitingStaff")} + )} +
+ {row.replyAgeMinutes === null + ? "—" + : row.replyAgeMinutes < 60 + ? t("ageMinutes", { count: row.replyAgeMinutes }) + : row.replyAgeMinutes < 1440 + ? t("ageHours", { + count: Math.floor(row.replyAgeMinutes / 60), + }) + : t("ageDays", { + count: Math.floor(row.replyAgeMinutes / 1440), + })} +
+
+ ), + }, { key: "date", label: t("colDate"), sortable: true }, ]; @@ -123,6 +160,14 @@ export function TicketsTable({ columns={columns} searchPlaceholder={t("searchPlaceholder")} presets={[ + { + label: t("awaitingStaff"), + params: { filter_status: "awaiting_staff", filter_assignee: "" }, + }, + { + label: t("assignedMine"), + params: { filter_status: "active", filter_assignee: "mine" }, + }, { label: t("presetActive"), params: { filter_status: "active" } }, { label: t("statusOpen"), params: { filter_status: "open" } }, { diff --git a/src/app/mod/tickets/desk/page.tsx b/src/app/mod/tickets/desk/page.tsx index 1bf4e3ec..24d4fdeb 100644 --- a/src/app/mod/tickets/desk/page.tsx +++ b/src/app/mod/tickets/desk/page.tsx @@ -19,7 +19,14 @@ import { import { StatusCard } from "@/components/admin/dashboard"; import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner"; import { requireModPermission } from "@/lib/admin/guard"; +import { + awaitingStaffCondition, + latestTicketReplyAt, + latestTicketReplyIsStaff, + ticketAssignmentCondition, +} from "@/lib/admin/ticket-desk-query"; import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts"; +import { ticketReplyAge } from "@/lib/admin/ticket-reply-age"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { db, User, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; @@ -42,7 +49,10 @@ export default async function ModTicketsDeskPage({ }: { searchParams: Promise>; }) { - await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]); + const staff = await requireModPermission([ + PERMS.MOD_TICKETS_VIEW, + PERMS.TICKETS_VIEW, + ]); const t = await getTranslations("pages.admin.tickets"); const raw = await searchParams; @@ -50,8 +60,12 @@ export default async function ModTicketsDeskPage({ const statusFilter = raw.filter_status || raw.status || "active"; const conditions: SQL[] = []; + const assignment = ticketAssignmentCondition(raw.filter_assignee, staff.id); + if (assignment) conditions.push(assignment); - if (statusFilter === "active") { + if (statusFilter === "awaiting_staff") { + if (awaitingStaffCondition) conditions.push(awaitingStaffCondition); + } else if (statusFilter === "active") { conditions.push(ne(WebsiteTicket.status, "closed")); } else if (statusFilter && statusFilter !== "all") { conditions.push(eq(WebsiteTicket.status, statusFilter)); @@ -137,6 +151,8 @@ export default async function ModTicketsDeskPage({ createdAt: WebsiteTicket.createdAt, creatorUsername: Creator.username, assigneeUsername: Assignee.username, + replyAt: latestTicketReplyAt, + replyIsStaff: latestTicketReplyIsStaff, }) .from(WebsiteTicket) .leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id)) @@ -170,6 +186,9 @@ export default async function ModTicketsDeskPage({ category: ticket.category, messages: msgMap.get(ticket.id) ?? 0, status: ticket.status, + awaitingStaff: + ticket.status !== "closed" && Number(ticket.replyIsStaff) === 0, + replyAgeMinutes: ticketReplyAge(ticket.replyAt), priority: ticket.priority, assignee: ticket.assigneeUsername ?? "—", date: formatDate(ticket.createdAt, "date"), @@ -203,7 +222,10 @@ export default async function ModTicketsDeskPage({ - {total === 0 && !parsed.search && statusFilter === "active" ? ( + {total === 0 && + !parsed.search && + statusFilter === "active" && + !raw.filter_assignee ? (
{t("noTickets")}
) : ( import("@/db/schema")); + +import { + awaitingStaffCondition, + latestTicketReplyAt, + ticketAssignmentCondition, +} from "./ticket-desk-query"; + +const dialect = new MySqlDialect(); +describe("support desk query", () => { + it("binds the signed-in staff ID and distinguishes unassigned tickets", () => { + const mine = ticketAssignmentCondition("mine", 123); + expect(mine && dialect.sqlToQuery(mine).params).toEqual([123]); + const none = ticketAssignmentCondition("unassigned", 123); + expect(none && dialect.sqlToQuery(none).sql).toContain("is null"); + expect(ticketAssignmentCondition("123 OR 1=1", 123)).toBeUndefined(); + }); + it("requires an active ticket and resolves equal timestamps by message id", () => { + expect(awaitingStaffCondition).toBeDefined(); + if (!awaitingStaffCondition) return; + const query = dialect.sqlToQuery(awaitingStaffCondition); + expect(query.params).toEqual(["closed", 0]); + expect(query.sql).toContain("m.is_staff"); + expect(query.sql).toContain("m.created_at DESC, m.id DESC LIMIT 1"); + expect(dialect.sqlToQuery(latestTicketReplyAt).sql).toContain("COALESCE"); + }); +}); diff --git a/src/lib/admin/ticket-desk-query.ts b/src/lib/admin/ticket-desk-query.ts new file mode 100644 index 00000000..e7676ba3 --- /dev/null +++ b/src/lib/admin/ticket-desk-query.ts @@ -0,0 +1,20 @@ +import { and, eq, isNotNull, isNull, ne, type SQL, sql } from "drizzle-orm"; +import { WebsiteTicket } from "@/lib/db"; + +/** Messages persist their staff role at send time; later rank changes do not alter the queue. */ +export const latestTicketReplyIsStaff = sql`COALESCE((SELECT m.is_staff FROM website_ticket_messages m WHERE m.ticket_id = ${WebsiteTicket.id} ORDER BY m.created_at DESC, m.id DESC LIMIT 1), 0)`; +export const latestTicketReplyAt = sql`COALESCE((SELECT m.created_at FROM website_ticket_messages m WHERE m.ticket_id = ${WebsiteTicket.id} ORDER BY m.created_at DESC, m.id DESC LIMIT 1), ${WebsiteTicket.createdAt})`; +export const awaitingStaffCondition = and( + ne(WebsiteTicket.status, "closed"), + eq(latestTicketReplyIsStaff, 0), +); + +export function ticketAssignmentCondition( + value: string | undefined, + userId: number, +): SQL | undefined { + if (value === "mine") return eq(WebsiteTicket.assigneeId, userId); + if (value === "unassigned") return isNull(WebsiteTicket.assigneeId); + if (value === "assigned") return isNotNull(WebsiteTicket.assigneeId); + return undefined; +} diff --git a/src/lib/admin/ticket-reply-age.test.ts b/src/lib/admin/ticket-reply-age.test.ts new file mode 100644 index 00000000..df459daa --- /dev/null +++ b/src/lib/admin/ticket-reply-age.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { ticketReplyAge } from "./ticket-reply-age"; + +describe("ticket reply age", () => { + it("measures elapsed minutes, including day boundaries", () => { + expect( + ticketReplyAge( + "2026-09-10T12:00:00Z", + Date.parse("2026-09-11T13:30:00Z"), + ), + ).toBe(1530); + }); + it("does not report negative or invalid ages", () => { + expect(ticketReplyAge(new Date(2000), 1000)).toBe(0); + expect(ticketReplyAge("invalid")).toBeNull(); + expect(ticketReplyAge(null)).toBeNull(); + }); +}); diff --git a/src/lib/admin/ticket-reply-age.ts b/src/lib/admin/ticket-reply-age.ts new file mode 100644 index 00000000..30dff492 --- /dev/null +++ b/src/lib/admin/ticket-reply-age.ts @@ -0,0 +1,10 @@ +export function ticketReplyAge( + value: Date | string | null, + now = Date.now(), +): number | null { + if (!value) return null; + const time = value instanceof Date ? value.getTime() : Date.parse(value); + return Number.isFinite(time) + ? Math.max(0, Math.floor((now - time) / 60000)) + : null; +}