feat(support): filter waiting tickets and staff assignments
This commit is contained in:
1 parent
5d18af932d
commit
25d0a13050
7 files changed
+173
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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" } },
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user