diff --git a/CLAUDE.md b/CLAUDE.md
index 851ff0f..f574d0e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -45,12 +45,15 @@ The client proxies `/api/*` requests to the server via Vite config (target is co
- Define shared Zod schemas in the `core` package under `core/schemas/` (e.g. `core/schemas/users.ts`) and import them in both client and server (e.g. `import { createUserSchema } from "core/schemas/users"`)
- Use Zod for validation (import from `zod/v4`)
- 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).
+- Parse and validate numeric ID route params with the shared `parseId` helper (`import { parseId } from "../lib/parse-id"`). Returns a positive integer or `null` for invalid values.
- 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`)
+- Use the `ErrorAlert` component for error messages (`import ErrorAlert from "@/components/ErrorAlert"`). For static messages: ` `. For mutation/query errors with automatic Axios message extraction: ` `.
+- Use the `ErrorMessage` component for field validation errors (`import ErrorMessage from "@/components/ErrorMessage"`): `{errors.name && }`
## Authentication
@@ -79,4 +82,6 @@ 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
+- **Only use for things that truly require a real browser + server** — never duplicate what unit tests already cover
+- Valid E2E scenarios: auth redirects, cross-page navigation, data persistence after reload, full-stack integration flows (e.g. webhook creates data → UI displays it)
+- Invalid E2E scenarios: rendering, display logic, component states, API call verification, form validation, error messages — use component tests for these
diff --git a/client/src/components/BackLink.tsx b/client/src/components/BackLink.tsx
new file mode 100644
index 0000000..9112c44
--- /dev/null
+++ b/client/src/components/BackLink.tsx
@@ -0,0 +1,19 @@
+import { Link } from "react-router";
+import { ArrowLeft } from "lucide-react";
+
+interface BackLinkProps {
+ to: string;
+ children: React.ReactNode;
+}
+
+export default function BackLink({ to, children }: BackLinkProps) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/client/src/components/ErrorAlert.tsx b/client/src/components/ErrorAlert.tsx
new file mode 100644
index 0000000..57de2ab
--- /dev/null
+++ b/client/src/components/ErrorAlert.tsx
@@ -0,0 +1,36 @@
+import axios from "axios";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { AlertCircle } from "lucide-react";
+
+interface ErrorAlertProps {
+ /** Direct message string to display. */
+ message?: string;
+ /** Error object — if an Axios error, the server message is extracted automatically. */
+ error?: Error | null;
+ /** Fallback message when `error` doesn't contain a server message. */
+ fallback?: string;
+ className?: string;
+}
+
+export function getErrorMessage(error: unknown, fallback: string): string {
+ if (axios.isAxiosError(error)) {
+ return error.response?.data?.error ?? fallback;
+ }
+ return fallback;
+}
+
+export default function ErrorAlert({
+ message,
+ error,
+ fallback = "Something went wrong",
+ className,
+}: ErrorAlertProps) {
+ const text = message ?? getErrorMessage(error, fallback);
+
+ return (
+
+
+ {text}
+
+ );
+}
diff --git a/client/src/components/ErrorMessage.tsx b/client/src/components/ErrorMessage.tsx
new file mode 100644
index 0000000..d82cc48
--- /dev/null
+++ b/client/src/components/ErrorMessage.tsx
@@ -0,0 +1,3 @@
+export default function ErrorMessage({ message }: { message?: string }) {
+ return
{message}
;
+}
diff --git a/client/src/components/ReplyForm.test.tsx b/client/src/components/ReplyForm.test.tsx
new file mode 100644
index 0000000..3be4adc
--- /dev/null
+++ b/client/src/components/ReplyForm.test.tsx
@@ -0,0 +1,130 @@
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import axios from "axios";
+import { renderWithQuery } from "@/test/render";
+import ReplyForm from "./ReplyForm";
+
+vi.mock("axios");
+const mockedAxios = vi.mocked(axios, { deep: true });
+
+const TICKET_ID = 42;
+
+beforeEach(() => {
+ vi.resetAllMocks();
+});
+
+function renderForm() {
+ const user = userEvent.setup();
+ renderWithQuery( );
+ return { user };
+}
+
+describe("ReplyForm", () => {
+ it("should render textarea and submit button", () => {
+ renderForm();
+
+ expect(screen.getByPlaceholderText("Type your reply...")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Send Reply" })).toBeInTheDocument();
+ });
+
+ it("should show validation error when submitting empty body", async () => {
+ const { user } = renderForm();
+
+ await user.click(screen.getByRole("button", { name: "Send Reply" }));
+
+ await waitFor(() => {
+ expect(screen.getByText("Reply body is required")).toBeInTheDocument();
+ });
+ expect(mockedAxios.post).not.toHaveBeenCalled();
+ });
+
+ it("should show validation error for whitespace-only body", async () => {
+ const { user } = renderForm();
+
+ await user.type(screen.getByPlaceholderText("Type your reply..."), " ");
+ await user.click(screen.getByRole("button", { name: "Send Reply" }));
+
+ await waitFor(() => {
+ expect(screen.getByText("Reply body is required")).toBeInTheDocument();
+ });
+ expect(mockedAxios.post).not.toHaveBeenCalled();
+ });
+
+ it("should call POST /api/tickets/:ticketId/replies with body on valid submit", async () => {
+ mockedAxios.post.mockResolvedValue({ data: { id: 1, body: "Hello" } });
+ const { user } = renderForm();
+
+ await user.type(screen.getByPlaceholderText("Type your reply..."), "Hello");
+ await user.click(screen.getByRole("button", { name: "Send Reply" }));
+
+ await waitFor(() => {
+ expect(mockedAxios.post).toHaveBeenCalledWith(
+ `/api/tickets/${TICKET_ID}/replies`,
+ { body: "Hello" }
+ );
+ });
+ });
+
+ it("should clear the textarea after successful submission", async () => {
+ mockedAxios.post.mockResolvedValue({ data: { id: 1, body: "Hello" } });
+ const { user } = renderForm();
+
+ const textarea = screen.getByPlaceholderText("Type your reply...");
+ await user.type(textarea, "Hello");
+ await user.click(screen.getByRole("button", { name: "Send Reply" }));
+
+ await waitFor(() => {
+ expect(textarea).toHaveValue("");
+ });
+ });
+
+ it("should show 'Sending...' and disable button while submitting", async () => {
+ mockedAxios.post.mockReturnValue(new Promise(() => {}));
+ const { user } = renderForm();
+
+ await user.type(screen.getByPlaceholderText("Type your reply..."), "Hello");
+ await user.click(screen.getByRole("button", { name: "Send Reply" }));
+
+ await waitFor(() => {
+ const button = screen.getByRole("button", { name: "Sending..." });
+ expect(button).toBeDisabled();
+ });
+ });
+
+ it("should display server error message from Axios response", async () => {
+ const error = new Error("Bad Request");
+ Object.assign(error, {
+ response: { status: 400, data: { error: "Ticket not found" } },
+ });
+ mockedAxios.post.mockRejectedValue(error);
+ mockedAxios.isAxiosError.mockReturnValue(true);
+ const { user } = renderForm();
+
+ await user.type(screen.getByPlaceholderText("Type your reply..."), "Hello");
+ await user.click(screen.getByRole("button", { name: "Send Reply" }));
+
+ await waitFor(() => {
+ expect(screen.getByText("Ticket not found")).toBeInTheDocument();
+ });
+ });
+
+ it("should show generic error for non-Axios errors", async () => {
+ mockedAxios.post.mockRejectedValue(new Error("Network failure"));
+ mockedAxios.isAxiosError.mockReturnValue(false);
+ const { user } = renderForm();
+
+ await user.type(screen.getByPlaceholderText("Type your reply..."), "Hello");
+ await user.click(screen.getByRole("button", { name: "Send Reply" }));
+
+ await waitFor(() => {
+ expect(screen.getByText("Failed to send reply")).toBeInTheDocument();
+ });
+ });
+
+ it("should not show error alert before submission", () => {
+ renderForm();
+
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/ReplyForm.tsx b/client/src/components/ReplyForm.tsx
new file mode 100644
index 0000000..0d7d696
--- /dev/null
+++ b/client/src/components/ReplyForm.tsx
@@ -0,0 +1,63 @@
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import axios from "axios";
+import { type Ticket } from "core/constants/ticket.ts";
+import { createReplySchema, type CreateReplyInput } from "core/schemas/replies.ts";
+import { Textarea } from "@/components/ui/textarea";
+import { Button } from "@/components/ui/button";
+import ErrorAlert from "@/components/ErrorAlert";
+import ErrorMessage from "@/components/ErrorMessage";
+
+interface ReplyFormProps {
+ ticket: Ticket;
+}
+
+export default function ReplyForm({ ticket }: ReplyFormProps) {
+ const ticketId = ticket.id;
+ const queryClient = useQueryClient();
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(createReplySchema),
+ });
+
+ const mutation = useMutation({
+ mutationFn: async (data: CreateReplyInput) => {
+ const { data: reply } = await axios.post(
+ `/api/tickets/${ticketId}/replies`,
+ data
+ );
+ return reply;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["replies", ticketId] });
+ reset();
+ },
+ });
+
+ return (
+
+ );
+}
diff --git a/client/src/components/ReplyThread.test.tsx b/client/src/components/ReplyThread.test.tsx
new file mode 100644
index 0000000..82f12f3
--- /dev/null
+++ b/client/src/components/ReplyThread.test.tsx
@@ -0,0 +1,160 @@
+import { screen, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import axios from "axios";
+import type { Ticket } from "core/constants/ticket.ts";
+import { renderWithQuery } from "@/test/render";
+import ReplyThread from "./ReplyThread";
+
+vi.mock("axios");
+const mockedAxios = vi.mocked(axios, { deep: true });
+
+const mockTicket: Ticket = {
+ id: 42,
+ subject: "Cannot login to my account",
+ body: "I need help logging in",
+ bodyHtml: null,
+ status: "open",
+ category: "technical_question",
+ senderName: "Alice Smith",
+ senderEmail: "alice@example.com",
+ assignedTo: null,
+ createdAt: "2025-03-01T10:00:00.000Z",
+ updatedAt: "2025-03-01T10:00:00.000Z",
+};
+
+beforeEach(() => {
+ vi.resetAllMocks();
+});
+
+describe("ReplyThread", () => {
+ it("should show skeletons while loading", () => {
+ mockedAxios.get.mockReturnValue(new Promise(() => {}));
+ renderWithQuery( );
+
+ expect(document.querySelectorAll("[data-slot='skeleton']").length).toBe(2);
+ });
+
+ it("should show 'No replies yet' when there are no replies", async () => {
+ mockedAxios.get.mockResolvedValue({ data: { replies: [] } });
+ renderWithQuery( );
+
+ await waitFor(() => {
+ expect(screen.getByText("No replies yet")).toBeInTheDocument();
+ });
+ });
+
+ it("should fetch replies for the ticket", async () => {
+ mockedAxios.get.mockResolvedValue({ data: { replies: [] } });
+ renderWithQuery( );
+
+ await waitFor(() => {
+ expect(mockedAxios.get).toHaveBeenCalledWith("/api/tickets/42/replies");
+ });
+ });
+
+ it("should display an error message when the request fails", async () => {
+ mockedAxios.get.mockRejectedValue(new Error("Network Error"));
+ renderWithQuery( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Failed to load replies")).toBeInTheDocument();
+ });
+ });
+
+ it("should display agent replies with the agent's name", async () => {
+ mockedAxios.get.mockResolvedValue({
+ data: {
+ replies: [
+ {
+ id: 1,
+ body: "I can help with that",
+ senderType: "agent",
+ user: { id: "agent-1", name: "Jane Doe" },
+ createdAt: "2025-03-01T11:00:00.000Z",
+ },
+ ],
+ },
+ });
+ renderWithQuery( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Jane Doe")).toBeInTheDocument();
+ });
+ expect(screen.getByText("I can help with that")).toBeInTheDocument();
+ expect(screen.getByText(/Agent/)).toBeInTheDocument();
+ });
+
+ it("should display customer replies with the ticket sender name", async () => {
+ mockedAxios.get.mockResolvedValue({
+ data: {
+ replies: [
+ {
+ id: 2,
+ body: "Thanks for the help",
+ senderType: "customer",
+ user: null,
+ createdAt: "2025-03-01T12:00:00.000Z",
+ },
+ ],
+ },
+ });
+ renderWithQuery( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Alice Smith")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Thanks for the help")).toBeInTheDocument();
+ expect(screen.getByText(/Customer/)).toBeInTheDocument();
+ });
+
+ it("should fall back to 'Agent' when agent reply has no user", async () => {
+ mockedAxios.get.mockResolvedValue({
+ data: {
+ replies: [
+ {
+ id: 3,
+ body: "Automated response",
+ senderType: "agent",
+ user: null,
+ createdAt: "2025-03-01T13:00:00.000Z",
+ },
+ ],
+ },
+ });
+ renderWithQuery( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Agent")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Automated response")).toBeInTheDocument();
+ });
+
+ it("should display multiple replies", async () => {
+ mockedAxios.get.mockResolvedValue({
+ data: {
+ replies: [
+ {
+ id: 1,
+ body: "First reply",
+ senderType: "agent",
+ user: { id: "agent-1", name: "Jane Doe" },
+ createdAt: "2025-03-01T11:00:00.000Z",
+ },
+ {
+ id: 2,
+ body: "Second reply",
+ senderType: "customer",
+ user: null,
+ createdAt: "2025-03-01T12:00:00.000Z",
+ },
+ ],
+ },
+ });
+ renderWithQuery( );
+
+ await waitFor(() => {
+ expect(screen.getByText("First reply")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Second reply")).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/ReplyThread.tsx b/client/src/components/ReplyThread.tsx
new file mode 100644
index 0000000..548d208
--- /dev/null
+++ b/client/src/components/ReplyThread.tsx
@@ -0,0 +1,77 @@
+import { useQuery } from "@tanstack/react-query";
+import axios from "axios";
+import { type Ticket } from "core/constants/ticket.ts";
+import { type SenderType, senderTypeLabel } from "core/constants/sender-type.ts";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import ErrorAlert from "@/components/ErrorAlert";
+
+interface Reply {
+ id: number;
+ body: string;
+ senderType: SenderType;
+ user: { id: string; name: string } | null;
+ createdAt: string;
+}
+
+interface ReplyThreadProps {
+ ticket: Ticket;
+}
+
+export default function ReplyThread({ ticket }: ReplyThreadProps) {
+ const { id: ticketId, senderName } = ticket;
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["replies", ticketId],
+ queryFn: async () => {
+ const { data } = await axios.get<{ replies: Reply[] }>(
+ `/api/tickets/${ticketId}/replies`
+ );
+ return data;
+ },
+ });
+
+ if (isLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return ;
+ }
+
+ if (!data?.replies.length) {
+ return No replies yet
;
+ }
+
+ return (
+
+ {data.replies.map((reply) => {
+ const isAgent = reply.senderType === "agent";
+ const displayName = isAgent
+ ? reply.user?.name ?? "Agent"
+ : senderName;
+
+ return (
+
+
+
+ {displayName}
+
+
+ {senderTypeLabel[reply.senderType]} ·{" "}
+ {new Date(reply.createdAt).toLocaleString()}
+
+
+
+ {reply.body}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/client/src/components/TicketDetail.test.tsx b/client/src/components/TicketDetail.test.tsx
new file mode 100644
index 0000000..f920dc8
--- /dev/null
+++ b/client/src/components/TicketDetail.test.tsx
@@ -0,0 +1,67 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import type { Ticket } from "core/constants/ticket.ts";
+import TicketDetail from "./TicketDetail";
+
+const mockTicket: Ticket = {
+ id: 1,
+ subject: "Cannot login to my account",
+ body: "I need help logging in",
+ bodyHtml: null,
+ status: "open",
+ category: "technical_question",
+ senderName: "Alice Smith",
+ senderEmail: "alice@example.com",
+ assignedTo: null,
+ createdAt: "2025-03-01T10:00:00.000Z",
+ updatedAt: "2025-03-01T12:00:00.000Z",
+};
+
+describe("TicketDetail", () => {
+ it("should display the ticket subject", () => {
+ render( );
+
+ expect(
+ screen.getByRole("heading", { name: "Cannot login to my account" })
+ ).toBeInTheDocument();
+ });
+
+ it("should display sender name and email", () => {
+ render( );
+
+ expect(
+ screen.getByText(/Alice Smith \(alice@example\.com\)/)
+ ).toBeInTheDocument();
+ });
+
+ it("should display created and updated dates", () => {
+ render( );
+
+ expect(screen.getByText(/Created:/)).toBeInTheDocument();
+ expect(screen.getByText(/Updated:/)).toBeInTheDocument();
+ });
+
+ it("should render the plain text body when bodyHtml is null", () => {
+ render( );
+
+ expect(screen.getByText("I need help logging in")).toBeInTheDocument();
+ });
+
+ it("should render HTML body when bodyHtml is present", () => {
+ const htmlTicket: Ticket = {
+ ...mockTicket,
+ bodyHtml: "Hello world
",
+ };
+ render( );
+
+ expect(screen.getByText("world")).toBeInTheDocument();
+ expect(screen.queryByText("I need help logging in")).not.toBeInTheDocument();
+ });
+
+ it("should display the message card with sender name", () => {
+ render( );
+
+ expect(screen.getByText("Message")).toBeInTheDocument();
+ expect(screen.getByText("From Alice Smith")).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/TicketDetail.tsx b/client/src/components/TicketDetail.tsx
new file mode 100644
index 0000000..6804590
--- /dev/null
+++ b/client/src/components/TicketDetail.tsx
@@ -0,0 +1,50 @@
+import { type Ticket } from "core/constants/ticket.ts";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+
+interface TicketDetailProps {
+ ticket: Ticket;
+}
+
+export default function TicketDetail({ ticket }: TicketDetailProps) {
+ return (
+ <>
+
+
{ticket.subject}
+
+
+ From:
+ {ticket.senderName} ({ticket.senderEmail})
+
+
+ Created:
+ {new Date(ticket.createdAt).toLocaleString()}
+
+
+ Updated:
+ {new Date(ticket.updatedAt).toLocaleString()}
+
+
+
+
+
+
+ Message
+ From {ticket.senderName}
+
+
+ {ticket.bodyHtml ? (
+
+ ) : (
+ {ticket.body}
+ )}
+
+
+ >
+ );
+}
diff --git a/client/src/components/TicketDetailSkeleton.tsx b/client/src/components/TicketDetailSkeleton.tsx
new file mode 100644
index 0000000..40979fa
--- /dev/null
+++ b/client/src/components/TicketDetailSkeleton.tsx
@@ -0,0 +1,14 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function TicketDetailSkeleton() {
+ return (
+
+ );
+}
diff --git a/client/src/components/UpdateTicket.tsx b/client/src/components/UpdateTicket.tsx
new file mode 100644
index 0000000..add7cce
--- /dev/null
+++ b/client/src/components/UpdateTicket.tsx
@@ -0,0 +1,114 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import axios from "axios";
+import { type Ticket } from "core/constants/ticket.ts";
+import { ticketStatuses, statusLabel } from "core/constants/ticket-status.ts";
+import { ticketCategories, categoryLabel } from "core/constants/ticket-category.ts";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+
+interface Agent {
+ id: string;
+ name: string;
+}
+
+interface UpdateTicketProps {
+ ticket: Ticket;
+}
+
+export default function UpdateTicket({ ticket }: UpdateTicketProps) {
+ const queryClient = useQueryClient();
+
+ const { data: agentsData } = useQuery({
+ queryKey: ["agents"],
+ queryFn: async () => {
+ const { data } = await axios.get<{ agents: Agent[] }>("/api/agents");
+ return data;
+ },
+ });
+
+ const updateMutation = useMutation({
+ mutationFn: async (body: Record) => {
+ const { data } = await axios.patch(`/api/tickets/${ticket.id}`, body);
+ return data;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["ticket", String(ticket.id)] });
+ },
+ });
+
+ return (
+
+
+ Status
+ updateMutation.mutate({ status: value })}
+ >
+
+
+
+
+ {ticketStatuses.map((s) => (
+
+ {statusLabel[s]}
+
+ ))}
+
+
+
+
+
+ Category
+
+ updateMutation.mutate({
+ category: value === "none" ? null : value,
+ })
+ }
+ >
+
+
+
+
+ None
+ {ticketCategories.map((c) => (
+
+ {categoryLabel[c]}
+
+ ))}
+
+
+
+
+
+ Assigned To
+
+ updateMutation.mutate({
+ assignedToId: value === "unassigned" ? null : value,
+ })
+ }
+ >
+
+
+
+
+ Unassigned
+ {agentsData?.agents.map((agent) => (
+
+ {agent.name}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/client/src/components/ui/textarea.tsx b/client/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..7f21b5e
--- /dev/null
+++ b/client/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/client/src/index.css b/client/src/index.css
index c619d95..575e407 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -125,6 +125,9 @@
body {
@apply bg-background text-foreground;
}
+ h2 {
+ @apply text-lg font-semibold;
+ }
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx
index 6c46fbb..e315444 100644
--- a/client/src/pages/LoginPage.tsx
+++ b/client/src/pages/LoginPage.tsx
@@ -14,8 +14,9 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { AlertCircle, Loader2 } from "lucide-react";
+import ErrorAlert from "@/components/ErrorAlert";
+import ErrorMessage from "@/components/ErrorMessage";
+import { Loader2 } from "lucide-react";
const loginSchema = z.object({
email: z.email("Please enter a valid email"),
@@ -73,10 +74,7 @@ export default function LoginPage() {
{serverError && (
-
-
- {serverError}
-
+
)}
@@ -88,9 +86,7 @@ export default function LoginPage() {
{...register("email")}
/>
{errors.email && (
-
- {errors.email.message}
-
+
)}
@@ -102,9 +98,7 @@ export default function LoginPage() {
{...register("password")}
/>
{errors.password && (
-
- {errors.password.message}
-
+
)}
diff --git a/client/src/pages/TicketDetailPage.test.tsx b/client/src/pages/TicketDetailPage.test.tsx
index 49db176..ecd2f5d 100644
--- a/client/src/pages/TicketDetailPage.test.tsx
+++ b/client/src/pages/TicketDetailPage.test.tsx
@@ -78,6 +78,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } });
+ if (url.includes("/replies"))
+ return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url"));
});
renderPage();
@@ -90,7 +92,7 @@ describe("TicketDetailPage", () => {
const comboboxes = screen.getAllByRole("combobox");
expect(comboboxes[0]).toHaveTextContent("Open");
- expect(comboboxes[1]).toHaveTextContent("Technical Question");
+ expect(comboboxes[1]).toHaveTextContent("Technical");
expect(
screen.getByText(/Alice Smith \(alice@example\.com\)/)
).toBeInTheDocument();
@@ -124,6 +126,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } });
+ if (url.includes("/replies"))
+ return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url"));
});
renderPage();
@@ -139,6 +143,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } });
+ if (url.includes("/replies"))
+ return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url"));
});
renderPage();
@@ -163,6 +169,8 @@ describe("TicketDetailPage", () => {
return Promise.resolve({ data: assignedTicket });
if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } });
+ if (url.includes("/replies"))
+ return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url"));
});
renderPage();
@@ -173,8 +181,10 @@ describe("TicketDetailPage", () => {
).toBeInTheDocument();
});
- const comboboxes = screen.getAllByRole("combobox");
- expect(comboboxes[2]).toHaveTextContent("Jane Doe");
+ await waitFor(() => {
+ const comboboxes = screen.getAllByRole("combobox");
+ expect(comboboxes[2]).toHaveTextContent("Jane Doe");
+ });
});
it("should call PATCH with assignedToId when selecting an agent", async () => {
@@ -183,6 +193,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } });
+ if (url.includes("/replies"))
+ return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url"));
});
mockedAxios.patch.mockResolvedValue({
@@ -223,6 +235,8 @@ describe("TicketDetailPage", () => {
return Promise.resolve({ data: assignedTicket });
if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } });
+ if (url.includes("/replies"))
+ return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url"));
});
mockedAxios.patch.mockResolvedValue({
@@ -260,6 +274,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } });
+ if (url.includes("/replies"))
+ return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url"));
});
renderPage();
@@ -288,6 +304,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } });
+ if (url.includes("/replies"))
+ return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url"));
});
mockedAxios.patch.mockResolvedValue({
@@ -323,6 +341,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } });
+ if (url.includes("/replies"))
+ return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url"));
});
renderPage();
@@ -334,15 +354,15 @@ describe("TicketDetailPage", () => {
});
const comboboxes = screen.getAllByRole("combobox");
- expect(comboboxes[1]).toHaveTextContent("Technical Question");
+ expect(comboboxes[1]).toHaveTextContent("Technical");
await user.click(comboboxes[1]);
await waitFor(() => {
expect(screen.getByRole("option", { name: "None" })).toBeInTheDocument();
- expect(screen.getByRole("option", { name: "General Question" })).toBeInTheDocument();
- expect(screen.getByRole("option", { name: "Technical Question" })).toBeInTheDocument();
- expect(screen.getByRole("option", { name: "Refund Request" })).toBeInTheDocument();
+ expect(screen.getByRole("option", { name: "General" })).toBeInTheDocument();
+ expect(screen.getByRole("option", { name: "Technical" })).toBeInTheDocument();
+ expect(screen.getByRole("option", { name: "Refund" })).toBeInTheDocument();
});
});
@@ -352,6 +372,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } });
+ if (url.includes("/replies"))
+ return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url"));
});
mockedAxios.patch.mockResolvedValue({
@@ -369,10 +391,10 @@ describe("TicketDetailPage", () => {
await user.click(comboboxes[1]);
await waitFor(() => {
- expect(screen.getByRole("option", { name: "Refund Request" })).toBeInTheDocument();
+ expect(screen.getByRole("option", { name: "Refund" })).toBeInTheDocument();
});
- await user.click(screen.getByRole("option", { name: "Refund Request" }));
+ await user.click(screen.getByRole("option", { name: "Refund" }));
await waitFor(() => {
expect(mockedAxios.patch).toHaveBeenCalledWith("/api/tickets/1", {
@@ -387,6 +409,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } });
+ if (url.includes("/replies"))
+ return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url"));
});
mockedAxios.patch.mockResolvedValue({
@@ -426,6 +450,8 @@ describe("TicketDetailPage", () => {
return Promise.resolve({ data: htmlTicket });
if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } });
+ if (url.includes("/replies"))
+ return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url"));
});
renderPage();
@@ -440,6 +466,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } });
+ if (url.includes("/replies"))
+ return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url"));
});
renderPage();
diff --git a/client/src/pages/TicketDetailPage.tsx b/client/src/pages/TicketDetailPage.tsx
index 81ccefc..6c3800b 100644
--- a/client/src/pages/TicketDetailPage.tsx
+++ b/client/src/pages/TicketDetailPage.tsx
@@ -1,220 +1,59 @@
-import { useParams, Link } from "react-router";
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useParams } from "react-router";
+import { useQuery } from "@tanstack/react-query";
import axios from "axios";
-import { type TicketStatus, ticketStatuses, statusLabel } from "core/constants/ticket-status.ts";
-import { type TicketCategory, ticketCategories, categoryLabel } from "core/constants/ticket-category.ts";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { Skeleton } from "@/components/ui/skeleton";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { AlertCircle, ArrowLeft } from "lucide-react";
-
-interface TicketDetail {
- id: number;
- subject: string;
- body: string;
- bodyHtml: string | null;
- status: TicketStatus;
- category: TicketCategory | null;
- senderName: string;
- senderEmail: string;
- assignedTo: { id: string; name: string } | null;
- createdAt: string;
- updatedAt: string;
-}
-
-interface Agent {
- id: string;
- name: string;
-}
+import { type Ticket } from "core/constants/ticket.ts";
+import ErrorAlert from "@/components/ErrorAlert";
+import BackLink from "@/components/BackLink";
+import TicketDetailSkeleton from "@/components/TicketDetailSkeleton";
+import TicketDetail from "@/components/TicketDetail";
+import UpdateTicket from "@/components/UpdateTicket";
+import ReplyThread from "@/components/ReplyThread";
+import ReplyForm from "@/components/ReplyForm";
export default function TicketDetailPage() {
const { id } = useParams<{ id: string }>();
- const queryClient = useQueryClient();
const { data: ticket, isLoading, error } = useQuery({
queryKey: ["ticket", id],
queryFn: async () => {
- const { data } = await axios.get(`/api/tickets/${id}`);
+ const { data } = await axios.get(`/api/tickets/${id}`);
return data;
},
});
- const { data: agentsData } = useQuery({
- queryKey: ["agents"],
- queryFn: async () => {
- const { data } = await axios.get<{ agents: Agent[] }>("/api/agents");
- return data;
- },
- });
-
- const updateMutation = useMutation({
- mutationFn: async (body: Record) => {
- const { data } = await axios.patch(
- `/api/tickets/${id}`,
- body
- );
- return data;
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["ticket", id] });
- },
- });
-
return (
-
-
- Back to tickets
-
+
Back to tickets
- {isLoading && (
-
- )}
+ {isLoading &&
}
{error && (
-
-
-
- {axios.isAxiosError(error) && error.response?.status === 404
+
-
+ : "Failed to load ticket"
+ }
+ />
)}
{ticket && (
-
-
{ticket.subject}
-
-
- From:
- {ticket.senderName} ({ticket.senderEmail})
-
-
- Created:
- {new Date(ticket.createdAt).toLocaleString()}
-
-
- Updated:
- {new Date(ticket.updatedAt).toLocaleString()}
-
-
+
+
+
+
Replies
+
-
-
- Message
-
- From {ticket.senderName}
-
-
-
- {ticket.bodyHtml ? (
-
- ) : (
- {ticket.body}
- )}
-
-
-
-
-
-
- Status
-
- updateMutation.mutate({ status: value })
- }
- >
-
-
-
-
- {ticketStatuses.map((s) => (
-
- {statusLabel[s]}
-
- ))}
-
-
-
-
-
- Category
-
- updateMutation.mutate({
- category: value === "none" ? null : value,
- })
- }
- >
-
-
-
-
- None
- {ticketCategories.map((c) => (
-
- {categoryLabel[c]}
-
- ))}
-
-
-
-
-
-
Assigned To
-
- updateMutation.mutate({
- assignedToId: value === "unassigned" ? null : value,
- })
- }
- >
-
-
-
-
- Unassigned
- {agentsData?.agents.map((agent) => (
-
- {agent.name}
-
- ))}
-
-
+
+
Add a Reply
+
+
+
)}
diff --git a/client/src/pages/TicketsTable.tsx b/client/src/pages/TicketsTable.tsx
index 914fcb6..03012bb 100644
--- a/client/src/pages/TicketsTable.tsx
+++ b/client/src/pages/TicketsTable.tsx
@@ -10,9 +10,9 @@ import {
getCoreRowModel,
flexRender,
} from "@tanstack/react-table";
-import { type TicketStatus, statusVariant } from "core/constants/ticket-status.ts";
-import { type TicketCategory } from "core/constants/ticket-category.ts";
-import { Alert, AlertDescription } from "@/components/ui/alert";
+import { type Ticket } from "core/constants/ticket.ts";
+import { statusVariant } from "core/constants/ticket-status.ts";
+import ErrorAlert from "@/components/ErrorAlert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -25,7 +25,6 @@ import {
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import {
- AlertCircle,
ArrowDown,
ArrowUp,
ArrowUpDown,
@@ -36,16 +35,6 @@ import {
} from "lucide-react";
import type { TicketFilters } from "./TicketsPage";
-interface Ticket {
- id: number;
- subject: string;
- status: TicketStatus;
- category: TicketCategory | null;
- senderName: string;
- senderEmail: string;
- createdAt: string;
-}
-
interface TicketsResponse {
tickets: Ticket[];
total: number;
@@ -165,12 +154,7 @@ export default function TicketsTable({ filters }: { filters: TicketFilters }) {
});
if (error) {
- return (
-
-
- Failed to fetch tickets
-
- );
+ return
;
}
return (
diff --git a/client/src/pages/UserForm.tsx b/client/src/pages/UserForm.tsx
index b264bcc..e78959d 100644
--- a/client/src/pages/UserForm.tsx
+++ b/client/src/pages/UserForm.tsx
@@ -8,11 +8,11 @@ import {
} from "core/schemas/users";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
-import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { AlertCircle } from "lucide-react";
+import ErrorAlert from "@/components/ErrorAlert";
+import ErrorMessage from "@/components/ErrorMessage";
interface UserData {
id: string;
@@ -55,13 +55,6 @@ export default function UserForm({ user, onSuccess }: UserFormProps) {
},
});
- const serverError =
- mutation.error && axios.isAxiosError(mutation.error)
- ? mutation.error.response?.data?.error ?? `Failed to ${isEdit ? "update" : "create"} user`
- : mutation.error
- ? `Failed to ${isEdit ? "update" : "create"} user`
- : null;
-
return (
mutation.mutate(data))}
@@ -77,9 +70,7 @@ export default function UserForm({ user, onSuccess }: UserFormProps) {
{...form.register("name")}
/>
{form.formState.errors.name && (
-
- {form.formState.errors.name.message}
-
+
)}
@@ -93,9 +84,7 @@ export default function UserForm({ user, onSuccess }: UserFormProps) {
{...form.register("email")}
/>
{form.formState.errors.email && (
-
- {form.formState.errors.email.message}
-
+
)}
@@ -109,16 +98,14 @@ export default function UserForm({ user, onSuccess }: UserFormProps) {
{...form.register("password")}
/>
{form.formState.errors.password && (
-
- {form.formState.errors.password.message}
-
+
)}
- {serverError && (
-
-
- {serverError}
-
+ {mutation.error && (
+
)}
diff --git a/client/src/pages/UsersPage.tsx b/client/src/pages/UsersPage.tsx
index be6ed31..a975698 100644
--- a/client/src/pages/UsersPage.tsx
+++ b/client/src/pages/UsersPage.tsx
@@ -18,8 +18,8 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { AlertCircle, Plus } from "lucide-react";
+import ErrorAlert from "@/components/ErrorAlert";
+import { Plus } from "lucide-react";
import UserForm from "./UserForm";
import UsersTable from "./UsersTable";
@@ -87,10 +87,7 @@ export default function UsersPage() {
{deleteMutation.isError && (
-
-
- Failed to delete user
-
+
)}
Cancel
diff --git a/client/src/pages/UsersTable.tsx b/client/src/pages/UsersTable.tsx
index 8b64f92..26981d2 100644
--- a/client/src/pages/UsersTable.tsx
+++ b/client/src/pages/UsersTable.tsx
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Role } from "core/constants/role.ts";
-import { Alert, AlertDescription } from "@/components/ui/alert";
+import ErrorAlert from "@/components/ErrorAlert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -13,7 +13,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
-import { AlertCircle, Pencil, Trash2 } from "lucide-react";
+import { Pencil, Trash2 } from "lucide-react";
interface User {
id: string;
@@ -42,12 +42,7 @@ export default function UsersTable({ onEdit, onDelete }: UsersTableProps) {
});
if (error) {
- return (
-
-
- Failed to fetch users
-
- );
+ return ;
}
return (
diff --git a/core/constants/sender-type.ts b/core/constants/sender-type.ts
new file mode 100644
index 0000000..97e770e
--- /dev/null
+++ b/core/constants/sender-type.ts
@@ -0,0 +1,8 @@
+export const senderTypes = ["agent", "customer"] as const;
+
+export type SenderType = (typeof senderTypes)[number];
+
+export const senderTypeLabel: Record = {
+ agent: "Agent",
+ customer: "Customer",
+};
diff --git a/core/constants/ticket.ts b/core/constants/ticket.ts
new file mode 100644
index 0000000..c46a90c
--- /dev/null
+++ b/core/constants/ticket.ts
@@ -0,0 +1,16 @@
+import { type TicketStatus } from "./ticket-status";
+import { type TicketCategory } from "./ticket-category";
+
+export interface Ticket {
+ id: number;
+ subject: string;
+ body: string;
+ bodyHtml: string | null;
+ status: TicketStatus;
+ category: TicketCategory | null;
+ senderName: string;
+ senderEmail: string;
+ assignedTo: { id: string; name: string } | null;
+ createdAt: string;
+ updatedAt: string;
+}
diff --git a/core/schemas/replies.ts b/core/schemas/replies.ts
new file mode 100644
index 0000000..a3c44a6
--- /dev/null
+++ b/core/schemas/replies.ts
@@ -0,0 +1,7 @@
+import { z } from "zod/v4";
+
+export const createReplySchema = z.object({
+ body: z.string().trim().min(1, "Reply body is required"),
+});
+
+export type CreateReplyInput = z.infer;
diff --git a/e2e/tests/ticket-detail.spec.ts b/e2e/tests/ticket-detail.spec.ts
new file mode 100644
index 0000000..70de20c
--- /dev/null
+++ b/e2e/tests/ticket-detail.spec.ts
@@ -0,0 +1,238 @@
+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!;
+
+/**
+ * Creates a ticket via the inbound email webhook and returns the ticket object.
+ */
+async function createTicketViaWebhook(
+ request: Parameters[1]>[0]["request"],
+ payload: InboundEmailInput
+) {
+ 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 as {
+ id: number;
+ subject: string;
+ senderName: string;
+ senderEmail: string;
+ body: string;
+ bodyHtml: string | null;
+ status: string;
+ category: string | null;
+ };
+}
+
+/**
+ * Builds a unique inbound-email payload so tests don't collide with each other.
+ */
+function createTestPayload(
+ uniqueId: string,
+ overrides?: Partial
+): InboundEmailInput {
+ return {
+ from: `sender-${uniqueId}@example.com`,
+ fromName: `Test Sender ${uniqueId}`,
+ subject: `Test Subject ${uniqueId}`,
+ body: `This is the body of ticket ${uniqueId}`,
+ ...overrides,
+ };
+}
+
+test.describe("Ticket Detail Page", () => {
+ test("should redirect unauthenticated user to login", async ({
+ page,
+ request,
+ }) => {
+ const uniqueId = `nav-unauth-${Date.now()}`;
+ const ticket = await createTicketViaWebhook(
+ request,
+ createTestPayload(uniqueId)
+ );
+
+ await page.goto(`/tickets/${ticket.id}`);
+
+ await expect(page).toHaveURL("/login");
+ });
+
+ test("should persist ticket updates after page reload", async ({
+ page,
+ request,
+ }) => {
+ const uniqueId = `persist-updates-${Date.now()}`;
+ const ticket = await createTicketViaWebhook(
+ request,
+ createTestPayload(uniqueId)
+ );
+
+ await loginAsAdmin(page);
+ await page.goto(`/tickets/${ticket.id}`);
+
+ // Update status
+ const statusPatch = page.waitForResponse(
+ (resp) =>
+ resp.url().includes(`/api/tickets/${ticket.id}`) &&
+ resp.request().method() === "PATCH" &&
+ resp.status() === 200
+ );
+ await page.getByRole("combobox").filter({ hasText: "Open" }).click();
+ await page.getByRole("option", { name: /^Resolved$/ }).click();
+ await statusPatch;
+
+ // Update category
+ const categoryPatch = page.waitForResponse(
+ (resp) =>
+ resp.url().includes(`/api/tickets/${ticket.id}`) &&
+ resp.request().method() === "PATCH" &&
+ resp.status() === 200
+ );
+ await page.getByRole("combobox").filter({ hasText: "None" }).click();
+ await page.getByRole("option", { name: /^Technical$/ }).click();
+ await categoryPatch;
+
+ // Update assignment
+ const assignPatch = page.waitForResponse(
+ (resp) =>
+ resp.url().includes(`/api/tickets/${ticket.id}`) &&
+ resp.request().method() === "PATCH" &&
+ resp.status() === 200
+ );
+ await page
+ .getByRole("combobox")
+ .filter({ hasText: "Unassigned" })
+ .click();
+ await page.getByRole("option", { name: /^Admin$/ }).click();
+ await assignPatch;
+
+ // Reload and verify all changes persisted
+ await page.reload();
+
+ await expect(
+ page.getByRole("combobox").filter({ hasText: "Resolved" })
+ ).toBeVisible();
+ await expect(
+ page.getByRole("combobox").filter({ hasText: "Technical" })
+ ).toBeVisible();
+ await expect(
+ page.getByRole("combobox").filter({ hasText: "Admin" })
+ ).toBeVisible();
+ });
+
+ test("should persist replies after page reload", async ({
+ page,
+ request,
+ }) => {
+ const uniqueId = `persist-reply-${Date.now()}`;
+ const ticket = await createTicketViaWebhook(
+ request,
+ createTestPayload(uniqueId)
+ );
+
+ await loginAsAdmin(page);
+ await page.goto(`/tickets/${ticket.id}`);
+
+ const replyText = `Persisted reply for ${uniqueId}`;
+
+ const postPromise = page.waitForResponse(
+ (resp) =>
+ resp.url().includes(`/api/tickets/${ticket.id}/replies`) &&
+ resp.request().method() === "POST" &&
+ resp.status() === 201
+ );
+
+ await page.getByPlaceholder(/type your reply/i).fill(replyText);
+ await page.getByRole("button", { name: /send reply/i }).click();
+ await postPromise;
+
+ await page.reload();
+
+ await expect(page.getByText(replyText)).toBeVisible();
+ });
+
+ test("should complete full agent workflow: navigate, view, update, reply, and return to list", async ({
+ page,
+ request,
+ }) => {
+ const uniqueId = `workflow-${Date.now()}`;
+ const payload = createTestPayload(uniqueId, {
+ body: `Customer question for ${uniqueId}`,
+ });
+ const ticket = await createTicketViaWebhook(request, payload);
+
+ await loginAsAdmin(page);
+
+ // Navigate from list to detail
+ await page.goto("/tickets");
+ const subjectLink = page.getByRole("link", {
+ name: ticket.subject,
+ exact: true,
+ });
+ await expect(subjectLink).toBeVisible();
+ await subjectLink.click();
+ await expect(page).toHaveURL(`/tickets/${ticket.id}`);
+
+ // Verify ticket details
+ await expect(
+ page.getByRole("heading", { name: ticket.subject })
+ ).toBeVisible();
+ const fromRow = page.locator("div").filter({ hasText: /^From:\s/ });
+ await expect(fromRow.first()).toContainText(ticket.senderName);
+ await expect(fromRow.first()).toContainText(ticket.senderEmail);
+ await expect(page.getByText(ticket.body, { exact: true })).toBeVisible();
+
+ // Update status
+ const statusPatch = page.waitForResponse(
+ (resp) =>
+ resp.url().includes(`/api/tickets/${ticket.id}`) &&
+ resp.request().method() === "PATCH" &&
+ resp.status() === 200
+ );
+ await page.getByRole("combobox").filter({ hasText: "Open" }).click();
+ await page.getByRole("option", { name: /^Resolved$/ }).click();
+ await statusPatch;
+ await expect(
+ page.getByRole("combobox").filter({ hasText: "Resolved" })
+ ).toBeVisible();
+
+ // Update category
+ const categoryPatch = page.waitForResponse(
+ (resp) =>
+ resp.url().includes(`/api/tickets/${ticket.id}`) &&
+ resp.request().method() === "PATCH" &&
+ resp.status() === 200
+ );
+ await page.getByRole("combobox").filter({ hasText: "None" }).click();
+ await page.getByRole("option", { name: /^Technical$/ }).click();
+ await categoryPatch;
+ await expect(
+ page.getByRole("combobox").filter({ hasText: "Technical" })
+ ).toBeVisible();
+
+ // Add a reply
+ const replyText = `Agent resolution note for ${uniqueId}`;
+ const postPromise = page.waitForResponse(
+ (resp) =>
+ resp.url().includes(`/api/tickets/${ticket.id}/replies`) &&
+ resp.request().method() === "POST" &&
+ resp.status() === 201
+ );
+ await page.getByPlaceholder(/type your reply/i).fill(replyText);
+ await page.getByRole("button", { name: /send reply/i }).click();
+ await postPromise;
+ await expect(page.getByText(replyText)).toBeVisible();
+
+ // Navigate back to list
+ await page.getByRole("link", { name: /back to tickets/i }).click();
+ await expect(page).toHaveURL("/tickets");
+ });
+});
diff --git a/server/prisma/migrations/20260224164447_add_reply_model/migration.sql b/server/prisma/migrations/20260224164447_add_reply_model/migration.sql
new file mode 100644
index 0000000..200f574
--- /dev/null
+++ b/server/prisma/migrations/20260224164447_add_reply_model/migration.sql
@@ -0,0 +1,20 @@
+-- CreateEnum
+CREATE TYPE "SenderType" AS ENUM ('agent', 'customer');
+
+-- CreateTable
+CREATE TABLE "reply" (
+ "id" SERIAL NOT NULL,
+ "body" TEXT NOT NULL,
+ "senderType" "SenderType" NOT NULL,
+ "ticketId" INTEGER NOT NULL,
+ "userId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "reply_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "reply" ADD CONSTRAINT "reply_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "reply" ADD CONSTRAINT "reply_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 6403220..b3ee8b8 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -24,6 +24,11 @@ enum TicketStatus {
closed
}
+enum SenderType {
+ agent
+ customer
+}
+
enum TicketCategory {
general_question
technical_question
@@ -43,6 +48,7 @@ model User {
sessions Session[]
accounts Account[]
assignedTickets Ticket[]
+ replies Reply[]
@@map("user")
}
@@ -93,10 +99,24 @@ model Ticket {
assignedTo User? @relation(fields: [assignedToId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+ replies Reply[]
@@map("ticket")
}
+model Reply {
+ id Int @id @default(autoincrement())
+ body String
+ senderType SenderType
+ ticketId Int
+ ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
+ userId String?
+ user User? @relation(fields: [userId], references: [id])
+ createdAt DateTime @default(now())
+
+ @@map("reply")
+}
+
model Verification {
id String @id
identifier String
diff --git a/server/src/index.ts b/server/src/index.ts
index 46b53e9..9a6e8ac 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -9,6 +9,7 @@ import usersRouter from "./routes/users";
import ticketsRouter from "./routes/tickets";
import agentsRouter from "./routes/agents";
import webhooksRouter from "./routes/webhooks";
+import repliesRouter from "./routes/replies";
if (!process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET environment variable is required");
@@ -57,6 +58,7 @@ app.get("/api/me", requireAuth, (req, res) => {
app.use("/api/users", usersRouter);
app.use("/api/tickets", ticketsRouter);
app.use("/api/agents", agentsRouter);
+app.use("/api/tickets/:ticketId/replies", repliesRouter);
app.use("/api/webhooks", webhooksRouter);
if (!process.env.WEBHOOK_SECRET) {
diff --git a/server/src/lib/parse-id.ts b/server/src/lib/parse-id.ts
new file mode 100644
index 0000000..add7a0c
--- /dev/null
+++ b/server/src/lib/parse-id.ts
@@ -0,0 +1,4 @@
+export function parseId(raw: unknown): number | null {
+ const id = Number(raw);
+ return Number.isInteger(id) && id > 0 ? id : null;
+}
diff --git a/server/src/routes/replies.ts b/server/src/routes/replies.ts
new file mode 100644
index 0000000..b1efab4
--- /dev/null
+++ b/server/src/routes/replies.ts
@@ -0,0 +1,61 @@
+import { Router } from "express";
+import { requireAuth } from "../middleware/require-auth";
+import { validate } from "../lib/validate";
+import { parseId } from "../lib/parse-id";
+import { createReplySchema } from "core/schemas/replies.ts";
+import prisma from "../db";
+
+const router = Router({ mergeParams: true });
+
+router.get("/", requireAuth, async (req, res) => {
+ const ticketId = parseId(req.params.ticketId);
+ if (!ticketId) {
+ res.status(400).json({ error: "Invalid ticket ID" });
+ return;
+ }
+
+ const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
+ if (!ticket) {
+ res.status(404).json({ error: "Ticket not found" });
+ return;
+ }
+
+ const replies = await prisma.reply.findMany({
+ where: { ticketId },
+ orderBy: { createdAt: "asc" },
+ include: { user: { select: { id: true, name: true } } },
+ });
+
+ res.json({ replies });
+});
+
+router.post("/", requireAuth, async (req, res) => {
+ const ticketId = parseId(req.params.ticketId);
+ if (!ticketId) {
+ res.status(400).json({ error: "Invalid ticket ID" });
+ return;
+ }
+
+ const data = validate(createReplySchema, req.body, res);
+ if (!data) return;
+
+ const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
+ if (!ticket) {
+ res.status(404).json({ error: "Ticket not found" });
+ return;
+ }
+
+ const reply = await prisma.reply.create({
+ data: {
+ body: data.body,
+ senderType: "agent",
+ ticketId,
+ userId: req.user.id,
+ },
+ include: { user: { select: { id: true, name: true } } },
+ });
+
+ res.status(201).json(reply);
+});
+
+export default router;
diff --git a/server/src/routes/tickets.ts b/server/src/routes/tickets.ts
index fbbff8f..89c0052 100644
--- a/server/src/routes/tickets.ts
+++ b/server/src/routes/tickets.ts
@@ -1,6 +1,7 @@
import { Router } from "express";
import { requireAuth } from "../middleware/require-auth";
import { validate } from "../lib/validate";
+import { parseId } from "../lib/parse-id";
import { ticketListQuerySchema, updateTicketSchema } from "core/schemas/tickets.ts";
import prisma from "../db";
import type { Prisma } from "../generated/prisma/client";
@@ -52,8 +53,8 @@ router.get("/", requireAuth, async (req, res) => {
});
router.get("/:id", requireAuth, async (req, res) => {
- const id = Number(req.params.id);
- if (!Number.isInteger(id) || id <= 0) {
+ const id = parseId(req.params.id);
+ if (!id) {
res.status(400).json({ error: "Invalid ticket ID" });
return;
}
@@ -74,8 +75,8 @@ router.get("/:id", requireAuth, async (req, res) => {
});
router.patch("/:id", requireAuth, async (req, res) => {
- const id = Number(req.params.id);
- if (!Number.isInteger(id) || id <= 0) {
+ const id = parseId(req.params.id);
+ if (!id) {
res.status(400).json({ error: "Invalid ticket ID" });
return;
}
diff --git a/server/src/routes/webhooks.ts b/server/src/routes/webhooks.ts
index 807cd1a..5426b5a 100644
--- a/server/src/routes/webhooks.ts
+++ b/server/src/routes/webhooks.ts
@@ -26,6 +26,14 @@ router.post("/inbound-email", requireWebhookSecret, async (req, res) => {
});
if (existingTicket) {
+ await prisma.reply.create({
+ data: {
+ body: data.body,
+ senderType: "customer",
+ ticketId: existingTicket.id,
+ userId: null,
+ },
+ });
res.status(200).json({ ticket: existingTicket });
return;
}