From 5bf969ae7bb19e62f09cca3457c88afb160f517f Mon Sep 17 00:00:00 2001 From: Moshfegh Hamedani Date: Tue, 24 Feb 2026 12:15:51 -0800 Subject: [PATCH] Add the ability to respond to tickets --- CLAUDE.md | 7 +- client/src/components/BackLink.tsx | 19 ++ client/src/components/ErrorAlert.tsx | 36 +++ client/src/components/ErrorMessage.tsx | 3 + client/src/components/ReplyForm.test.tsx | 130 ++++++++++ client/src/components/ReplyForm.tsx | 63 +++++ client/src/components/ReplyThread.test.tsx | 160 ++++++++++++ client/src/components/ReplyThread.tsx | 77 ++++++ client/src/components/TicketDetail.test.tsx | 67 +++++ client/src/components/TicketDetail.tsx | 50 ++++ .../src/components/TicketDetailSkeleton.tsx | 14 ++ client/src/components/UpdateTicket.tsx | 114 +++++++++ client/src/components/ui/textarea.tsx | 18 ++ client/src/index.css | 3 + client/src/pages/LoginPage.tsx | 18 +- client/src/pages/TicketDetailPage.test.tsx | 46 +++- client/src/pages/TicketDetailPage.tsx | 219 +++------------- client/src/pages/TicketsTable.tsx | 24 +- client/src/pages/UserForm.tsx | 33 +-- client/src/pages/UsersPage.tsx | 9 +- client/src/pages/UsersTable.tsx | 11 +- core/constants/sender-type.ts | 8 + core/constants/ticket.ts | 16 ++ core/schemas/replies.ts | 7 + e2e/tests/ticket-detail.spec.ts | 238 ++++++++++++++++++ .../migration.sql | 20 ++ server/prisma/schema.prisma | 20 ++ server/src/index.ts | 2 + server/src/lib/parse-id.ts | 4 + server/src/routes/replies.ts | 61 +++++ server/src/routes/tickets.ts | 9 +- server/src/routes/webhooks.ts | 8 + 32 files changed, 1241 insertions(+), 273 deletions(-) create mode 100644 client/src/components/BackLink.tsx create mode 100644 client/src/components/ErrorAlert.tsx create mode 100644 client/src/components/ErrorMessage.tsx create mode 100644 client/src/components/ReplyForm.test.tsx create mode 100644 client/src/components/ReplyForm.tsx create mode 100644 client/src/components/ReplyThread.test.tsx create mode 100644 client/src/components/ReplyThread.tsx create mode 100644 client/src/components/TicketDetail.test.tsx create mode 100644 client/src/components/TicketDetail.tsx create mode 100644 client/src/components/TicketDetailSkeleton.tsx create mode 100644 client/src/components/UpdateTicket.tsx create mode 100644 client/src/components/ui/textarea.tsx create mode 100644 core/constants/sender-type.ts create mode 100644 core/constants/ticket.ts create mode 100644 core/schemas/replies.ts create mode 100644 e2e/tests/ticket-detail.spec.ts create mode 100644 server/prisma/migrations/20260224164447_add_reply_model/migration.sql create mode 100644 server/src/lib/parse-id.ts create mode 100644 server/src/routes/replies.ts 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 ( +
mutation.mutate(data))} className="space-y-3"> + {mutation.error && ( + + )} + +
+