Add the ability to list tickets

This commit is contained in:
Moshfegh Hamedani 2026-02-19 11:12:49 -08:00
parent a6f0095bd1
commit 00d45441b5
12 changed files with 442 additions and 14 deletions

View file

@ -47,6 +47,7 @@ The client proxies `/api/*` requests to the server via Vite config (target is co
- Validate request bodies in route handlers using the shared `validate` helper (`import { validate } from "../lib/validate"`). It takes a Zod schema, the request body, and the `res` object — returns parsed data or `null` (after sending a 400 response).
- Do not wrap async route handlers in try/catch — Express 5 automatically catches rejected promises
- Use the shared `Role` constant instead of hardcoded `"admin"` / `"agent"` strings (import from `core/constants/role.ts`, e.g. `import { Role } from "core/constants/role.ts"`)
- Define shared constants and domain types in `core/constants/` as union types (not `enum` — the client has `erasableSyntaxOnly` enabled). Use `as const` objects when runtime access is needed (e.g. `Role`), and plain union types when only type checking is needed (e.g. `type TicketStatus = "open" | "resolved" | "closed"`).
- Use React Hook Form with Zod resolver for client-side form validation (`useForm` + `zodResolver` from `@hookform/resolvers/zod`)
- Use Axios for HTTP requests (not `fetch`)
- Use TanStack React Query (`useQuery`, `useMutation`) for server state management (not `useEffect` + `useState`)
@ -65,6 +66,8 @@ The client proxies `/api/*` requests to the server via Vite config (target is co
## Testing
- **Prefer component tests** for the majority of coverage (rendering, states, data display, error handling). Reserve E2E tests for things that truly need a real browser + server: navigation, auth redirects, and full-stack integration flows (e.g. webhook creates data that appears in the UI).
### Component Tests
- **Framework**: Vitest + React Testing Library
- Run with `cd client && bun run test` (single run) or `bun run test:watch` (watch mode)
@ -76,3 +79,4 @@ The client proxies `/api/*` requests to the server via Vite config (target is co
- **Framework**: Playwright
- Use the `e2e-test-writer` agent for writing Playwright E2E tests
- Run with `bun run test:e2e` from root
- Only use for navigation, auth redirects, and full-stack integration flows

View file

@ -5,6 +5,7 @@ import Layout from "./components/Layout";
import LoginPage from "./pages/LoginPage";
import HomePage from "./pages/HomePage";
import UsersPage from "./pages/UsersPage";
import TicketsPage from "./pages/TicketsPage";
function App() {
return (
@ -13,6 +14,7 @@ function App() {
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<HomePage />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route element={<AdminRoute />}>
<Route path="/users" element={<UsersPage />} />
</Route>

View file

@ -16,6 +16,9 @@ export default function Layout() {
<nav className="flex items-center justify-between bg-background border-b px-6 h-14">
<div className="flex items-center gap-6">
<Link to="/" className="text-lg font-bold hover:text-foreground transition-colors">Helpdesk</Link>
<Link to="/tickets" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
Tickets
</Link>
{session?.user?.role === Role.admin && (
<Link to="/users" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
Users

View file

@ -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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
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(<TicketsPage />);
await waitFor(() => {
expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets");
});
});
});

View file

@ -0,0 +1,12 @@
import TicketsTable from "./TicketsTable";
export default function TicketsPage() {
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Tickets</h1>
</div>
<TicketsTable />
</div>
);
}

View file

@ -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<TicketStatus, "default" | "secondary" | "outline"> = {
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 (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Failed to fetch tickets</AlertDescription>
</Alert>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Subject</TableHead>
<TableHead>Sender</TableHead>
<TableHead>Status</TableHead>
<TableHead>Category</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-48" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-40" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
</TableRow>
))
: tickets?.map((ticket) => (
<TableRow key={ticket.id}>
<TableCell>{ticket.subject}</TableCell>
<TableCell>
<div>{ticket.senderName}</div>
<div className="text-sm text-muted-foreground">
{ticket.senderEmail}
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariant[ticket.status]}>
{ticket.status}
</Badge>
</TableCell>
<TableCell>
{ticket.category ? (
<Badge variant="secondary">
{ticket.category.replace(/_/g, " ")}
</Badge>
) : (
"—"
)}
</TableCell>
<TableCell>
{new Date(ticket.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View file

@ -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];

View file

@ -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";

View file

@ -1,5 +1 @@
export enum TicketStatus {
open = "open",
resolved = "resolved",
closed = "closed",
}
export type TicketStatus = "open" | "resolved" | "closed";

98
e2e/tests/tickets.spec.ts Normal file
View file

@ -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<InboundEmailInput> & { 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>): 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();
});
});
});

View file

@ -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) {

View file

@ -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;