mirror of
https://github.com/mosh-hamedani/helpdesk.git
synced 2026-05-21 11:58:19 +02:00
Add the ability to list tickets
This commit is contained in:
parent
a6f0095bd1
commit
00d45441b5
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
168
client/src/pages/TicketsPage.test.tsx
Normal file
168
client/src/pages/TicketsPage.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
12
client/src/pages/TicketsPage.tsx
Normal file
12
client/src/pages/TicketsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
client/src/pages/TicketsTable.tsx
Normal file
119
client/src/pages/TicketsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
98
e2e/tests/tickets.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
23
server/src/routes/tickets.ts
Normal file
23
server/src/routes/tickets.ts
Normal 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;
|
||||
Loading…
Reference in a new issue