feat(support): filter waiting tickets and staff assignments
CI / check (push) Failing after 53s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-11 00:20:54 +02:00
1 parent 5d18af932d
commit 25d0a13050
7 files changed
+173 -6

No files matched your search

+24 -2
View File
@@ -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<number>`(
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({
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
</div>
{total === 0 && !parsed.search && statusFilter === "active" ? (
{total === 0 &&
!parsed.search &&
statusFilter === "active" &&
!raw.filter_assignee ? (
<div className="admin-empty">{t("noTickets")}</div>
) : (
<TicketsTable
+46 -1
View File
@@ -17,6 +17,8 @@ export interface TicketRow {
priority: string;
assignee: string;
date: string;
awaitingStaff: boolean;
replyAgeMinutes: number | null;
}
const STATUS_VARIANT: Record<
@@ -79,6 +81,7 @@ export function TicketsTable({
sortable: true,
filterKey: "filter_status",
filterOptions: [
{ label: t("awaitingStaff"), value: "awaiting_staff" },
{ label: t("statusOpen"), value: "open" },
{ label: t("statusInProgress"), value: "in_progress" },
{ label: t("statusWaiting"), value: "waiting" },
@@ -113,7 +116,41 @@ export function TicketsTable({
return <span className={`text-xs capitalize ${className}`}>{p}</span>;
},
},
{ 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) => (
<div>
{row.awaitingStaff && (
<Badge variant="outline">{t("awaitingStaff")}</Badge>
)}
<div className="text-xs text-muted-foreground">
{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),
})}
</div>
</div>
),
},
{ 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" } },
{
+25 -3
View File
@@ -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<Record<string, string>>;
}) {
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({
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
</div>
{total === 0 && !parsed.search && statusFilter === "active" ? (
{total === 0 &&
!parsed.search &&
statusFilter === "active" &&
!raw.filter_assignee ? (
<div className="admin-empty">{t("noTickets")}</div>
) : (
<TicketsTable
+30
View File
@@ -0,0 +1,30 @@
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", async () => 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");
});
});
+20
View File
@@ -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<number>`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<string>`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;
}
+18
View File
@@ -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();
});
});
+10
View File
@@ -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;
}