Helpdesk
+
+ Tickets
+
{session?.user?.role === Role.admin && (
Users
diff --git a/client/src/pages/TicketsPage.test.tsx b/client/src/pages/TicketsPage.test.tsx
new file mode 100644
index 0000000..38a7708
--- /dev/null
+++ b/client/src/pages/TicketsPage.test.tsx
@@ -0,0 +1,168 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import axios from "axios";
+import { renderWithQuery } from "@/test/render";
+import TicketsPage from "./TicketsPage";
+
+vi.mock("axios");
+const mockedAxios = vi.mocked(axios, { deep: true });
+
+const mockTickets = [
+ {
+ id: 1,
+ subject: "Cannot login to my account",
+ status: "open",
+ category: "technical_question",
+ senderName: "Alice Smith",
+ senderEmail: "alice@example.com",
+ createdAt: "2025-03-01T10:00:00.000Z",
+ },
+ {
+ id: 2,
+ subject: "Refund for order #123",
+ status: "resolved",
+ category: "refund_request",
+ senderName: "Bob Jones",
+ senderEmail: "bob@example.com",
+ createdAt: "2025-02-28T08:00:00.000Z",
+ },
+ {
+ id: 3,
+ subject: "How do I reset my password?",
+ status: "closed",
+ category: null,
+ senderName: "Charlie Brown",
+ senderEmail: "charlie@example.com",
+ createdAt: "2025-02-27T14:00:00.000Z",
+ },
+];
+
+beforeEach(() => {
+ vi.resetAllMocks();
+});
+
+describe("TicketsPage", () => {
+ it("should show skeleton rows while loading", () => {
+ mockedAxios.get.mockReturnValue(new Promise(() => {}));
+ renderWithQuery(
);
+
+ expect(screen.getByText("Tickets")).toBeInTheDocument();
+ expect(screen.getByText("Subject")).toBeInTheDocument();
+ expect(screen.getByText("Sender")).toBeInTheDocument();
+ expect(screen.getByText("Status")).toBeInTheDocument();
+ expect(screen.getByText("Category")).toBeInTheDocument();
+ expect(screen.getByText("Created")).toBeInTheDocument();
+ expect(document.querySelector("[data-slot='skeleton']")).toBeInTheDocument();
+ });
+
+ it("should display tickets in a table after loading", async () => {
+ mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
+ renderWithQuery(
);
+
+ await waitFor(() => {
+ expect(screen.getByText("Cannot login to my account")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Refund for order #123")).toBeInTheDocument();
+ expect(screen.getByText("How do I reset my password?")).toBeInTheDocument();
+ expect(document.querySelector("[data-slot='skeleton']")).not.toBeInTheDocument();
+ });
+
+ it("should display sender name and email", async () => {
+ mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
+ renderWithQuery(
);
+
+ await waitFor(() => {
+ expect(screen.getByText("Alice Smith")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("alice@example.com")).toBeInTheDocument();
+ expect(screen.getByText("Bob Jones")).toBeInTheDocument();
+ expect(screen.getByText("bob@example.com")).toBeInTheDocument();
+ });
+
+ it("should display status badges", async () => {
+ mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
+ renderWithQuery(
);
+
+ await waitFor(() => {
+ expect(screen.getByText("open")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("resolved")).toBeInTheDocument();
+ expect(screen.getByText("closed")).toBeInTheDocument();
+ });
+
+ it("should display category with underscores replaced by spaces", async () => {
+ mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
+ renderWithQuery(
);
+
+ await waitFor(() => {
+ expect(screen.getByText("technical question")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("refund request")).toBeInTheDocument();
+ });
+
+ it("should show dash for null category", async () => {
+ mockedAxios.get.mockResolvedValue({ data: { tickets: mockTickets } });
+ renderWithQuery(
);
+
+ await waitFor(() => {
+ expect(screen.getByText("How do I reset my password?")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("—")).toBeInTheDocument();
+ });
+
+ it("should format createdAt as a locale date string", async () => {
+ mockedAxios.get.mockResolvedValue({ data: { tickets: [mockTickets[0]] } });
+ renderWithQuery(
);
+
+ const expectedDate = new Date("2025-03-01T10:00:00.000Z").toLocaleDateString();
+ await waitFor(() => {
+ expect(screen.getByText(expectedDate)).toBeInTheDocument();
+ });
+ });
+
+ it("should show an error alert when the request fails", async () => {
+ mockedAxios.get.mockRejectedValue(new Error("Network Error"));
+ renderWithQuery(
);
+
+ await waitFor(() => {
+ expect(screen.getByText("Failed to fetch tickets")).toBeInTheDocument();
+ });
+ });
+
+ it("should not show the table when there is an error", async () => {
+ mockedAxios.get.mockRejectedValue(new Error("Network Error"));
+ renderWithQuery(
);
+
+ await waitFor(() => {
+ expect(screen.getByText("Failed to fetch tickets")).toBeInTheDocument();
+ });
+
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
+ });
+
+ it("should render an empty table body when there are no tickets", async () => {
+ mockedAxios.get.mockResolvedValue({ data: { tickets: [] } });
+ renderWithQuery(
);
+
+ await waitFor(() => {
+ expect(document.querySelector("[data-slot='skeleton']")).not.toBeInTheDocument();
+ });
+
+ expect(screen.getByRole("table")).toBeInTheDocument();
+ expect(screen.getAllByRole("row")).toHaveLength(1); // header row only
+ });
+
+ it("should call axios.get with /api/tickets", async () => {
+ mockedAxios.get.mockResolvedValue({ data: { tickets: [] } });
+ renderWithQuery(
);
+
+ await waitFor(() => {
+ expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets");
+ });
+ });
+});
diff --git a/client/src/pages/TicketsPage.tsx b/client/src/pages/TicketsPage.tsx
new file mode 100644
index 0000000..038d2ff
--- /dev/null
+++ b/client/src/pages/TicketsPage.tsx
@@ -0,0 +1,12 @@
+import TicketsTable from "./TicketsTable";
+
+export default function TicketsPage() {
+ return (
+
+ );
+}
diff --git a/client/src/pages/TicketsTable.tsx b/client/src/pages/TicketsTable.tsx
new file mode 100644
index 0000000..ba5fd1f
--- /dev/null
+++ b/client/src/pages/TicketsTable.tsx
@@ -0,0 +1,119 @@
+import { useQuery } from "@tanstack/react-query";
+import axios from "axios";
+import { type TicketStatus } from "core/constants/ticket-status.ts";
+import { type TicketCategory } from "core/constants/ticket-category.ts";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Skeleton } from "@/components/ui/skeleton";
+import { AlertCircle } from "lucide-react";
+
+interface Ticket {
+ id: number;
+ subject: string;
+ status: TicketStatus;
+ category: TicketCategory | null;
+ senderName: string;
+ senderEmail: string;
+ createdAt: string;
+}
+
+const statusVariant: Record
= {
+ open: "default",
+ resolved: "secondary",
+ closed: "outline",
+};
+
+export default function TicketsTable() {
+ const {
+ data: tickets,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ["tickets"],
+ queryFn: async () => {
+ const { data } = await axios.get<{ tickets: Ticket[] }>("/api/tickets");
+ return data.tickets;
+ },
+ });
+
+ if (error) {
+ return (
+
+
+ Failed to fetch tickets
+
+ );
+ }
+
+ return (
+
+
+
+ Subject
+ Sender
+ Status
+ Category
+ Created
+
+
+
+ {isLoading
+ ? Array.from({ length: 5 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))
+ : tickets?.map((ticket) => (
+
+ {ticket.subject}
+
+ {ticket.senderName}
+
+ {ticket.senderEmail}
+
+
+
+
+ {ticket.status}
+
+
+
+ {ticket.category ? (
+
+ {ticket.category.replace(/_/g, " ")}
+
+ ) : (
+ "—"
+ )}
+
+
+ {new Date(ticket.createdAt).toLocaleDateString()}
+
+
+ ))}
+
+
+ );
+}
diff --git a/core/constants/role.ts b/core/constants/role.ts
index 36358f2..f254707 100644
--- a/core/constants/role.ts
+++ b/core/constants/role.ts
@@ -1,4 +1,6 @@
-export enum Role {
- admin = "admin",
- agent = "agent",
-}
+export const Role = {
+ admin: "admin",
+ agent: "agent",
+} as const;
+
+export type Role = (typeof Role)[keyof typeof Role];
diff --git a/core/constants/ticket-category.ts b/core/constants/ticket-category.ts
index 4dc19d2..91ddb59 100644
--- a/core/constants/ticket-category.ts
+++ b/core/constants/ticket-category.ts
@@ -1,5 +1,4 @@
-export enum TicketCategory {
- general_question = "general_question",
- technical_question = "technical_question",
- refund_request = "refund_request",
-}
+export type TicketCategory =
+ | "general_question"
+ | "technical_question"
+ | "refund_request";
diff --git a/core/constants/ticket-status.ts b/core/constants/ticket-status.ts
index 03064be..1b129e2 100644
--- a/core/constants/ticket-status.ts
+++ b/core/constants/ticket-status.ts
@@ -1,5 +1 @@
-export enum TicketStatus {
- open = "open",
- resolved = "resolved",
- closed = "closed",
-}
+export type TicketStatus = "open" | "resolved" | "closed";
diff --git a/e2e/tests/tickets.spec.ts b/e2e/tests/tickets.spec.ts
new file mode 100644
index 0000000..adfffa2
--- /dev/null
+++ b/e2e/tests/tickets.spec.ts
@@ -0,0 +1,98 @@
+import { test, expect } from "@playwright/test";
+import { loginAsAdmin } from "../fixtures/auth";
+import type { InboundEmailInput } from "core/schemas/tickets.ts";
+
+const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;
+const API_BASE_URL = process.env.BETTER_AUTH_URL!;
+
+/**
+ * Helper to create a ticket via the inbound email webhook
+ */
+async function createTicketViaWebhook(
+ request: any,
+ payload: Partial & { from: string; fromName: string; subject: string; body: string }
+) {
+ const response = await request.post(
+ `${API_BASE_URL}/api/webhooks/inbound-email`,
+ {
+ headers: {
+ "x-webhook-secret": WEBHOOK_SECRET,
+ },
+ data: payload,
+ }
+ );
+
+ expect(response.status()).toBe(201);
+ const body = await response.json();
+ return body.ticket;
+}
+
+/**
+ * Helper to create a unique test payload
+ */
+function createTestPayload(uniqueId: string, overrides?: Partial): InboundEmailInput {
+ return {
+ from: `sender-${uniqueId}@example.com`,
+ fromName: `Test Sender ${uniqueId}`,
+ subject: `Test Subject ${uniqueId}`,
+ body: `Test body ${uniqueId}`,
+ ...overrides,
+ };
+}
+
+test.describe("Tickets Page", () => {
+ test.describe("Navigation", () => {
+ test("should show 'Tickets' link in navigation for authenticated user", async ({ page }) => {
+ await loginAsAdmin(page);
+
+ const ticketsLink = page.getByRole("link", { name: /^tickets$/i });
+ await expect(ticketsLink).toBeVisible();
+ });
+
+ test("should navigate to /tickets when clicking Tickets link", async ({ page }) => {
+ await loginAsAdmin(page);
+
+ await page.getByRole("link", { name: /^tickets$/i }).click();
+
+ await expect(page).toHaveURL("/tickets");
+ await expect(page.getByRole("heading", { name: /^tickets$/i })).toBeVisible();
+ });
+
+ test("should redirect to login when accessing /tickets without authentication", async ({ page }) => {
+ await page.goto("/tickets");
+
+ await expect(page).toHaveURL("/login");
+ });
+ });
+
+ test.describe("Integration with Webhook", () => {
+ test("should display ticket created via webhook endpoint", async ({ page, request }) => {
+ const uniqueId = `webhook-${Date.now()}`;
+ const payload = createTestPayload(uniqueId);
+
+ const ticket = await createTicketViaWebhook(request, payload);
+
+ await loginAsAdmin(page);
+ await page.goto("/tickets");
+
+ const row = page.getByRole("row").filter({ hasText: ticket.subject });
+ await expect(row).toBeVisible();
+ await expect(row.getByText(payload.fromName)).toBeVisible();
+ await expect(row.getByText(payload.from)).toBeVisible();
+ await expect(row.locator("text=open").first()).toBeVisible();
+ });
+
+ test("should show newly created ticket after page reload", async ({ page, request }) => {
+ await loginAsAdmin(page);
+ await page.goto("/tickets");
+
+ const uniqueId = `refresh-${Date.now()}`;
+ const payload = createTestPayload(uniqueId);
+ const ticket = await createTicketViaWebhook(request, payload);
+
+ await page.reload();
+
+ await expect(page.getByText(ticket.subject)).toBeVisible();
+ });
+ });
+});
diff --git a/server/src/index.ts b/server/src/index.ts
index 9e6c502..7ed629e 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -6,6 +6,7 @@ import { toNodeHandler } from "better-auth/node";
import { auth } from "./lib/auth";
import { requireAuth } from "./middleware/require-auth";
import usersRouter from "./routes/users";
+import ticketsRouter from "./routes/tickets";
import webhooksRouter from "./routes/webhooks";
if (!process.env.BETTER_AUTH_SECRET) {
@@ -53,6 +54,7 @@ app.get("/api/me", requireAuth, (req, res) => {
});
app.use("/api/users", usersRouter);
+app.use("/api/tickets", ticketsRouter);
app.use("/api/webhooks", webhooksRouter);
if (!process.env.WEBHOOK_SECRET) {
diff --git a/server/src/routes/tickets.ts b/server/src/routes/tickets.ts
new file mode 100644
index 0000000..c1740d9
--- /dev/null
+++ b/server/src/routes/tickets.ts
@@ -0,0 +1,23 @@
+import { Router } from "express";
+import { requireAuth } from "../middleware/require-auth";
+import prisma from "../db";
+
+const router = Router();
+
+router.get("/", requireAuth, async (req, res) => {
+ const tickets = await prisma.ticket.findMany({
+ select: {
+ id: true,
+ subject: true,
+ status: true,
+ category: true,
+ senderName: true,
+ senderEmail: true,
+ createdAt: true,
+ },
+ orderBy: { createdAt: "desc" },
+ });
+ res.json({ tickets });
+});
+
+export default router;