Add the ability to respond to tickets

This commit is contained in:
Moshfegh Hamedani 2026-02-24 12:15:51 -08:00
parent db29af6970
commit 5bf969ae7b
32 changed files with 1241 additions and 273 deletions

View file

@ -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"`) - 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`) - 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). - 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 - 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"`) - 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"`). - 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 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 Axios for HTTP requests (not `fetch`)
- Use TanStack React Query (`useQuery`, `useMutation`) for server state management (not `useEffect` + `useState`) - 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: `<ErrorAlert message="Failed to load data" />`. For mutation/query errors with automatic Axios message extraction: `<ErrorAlert error={mutation.error} fallback="Failed to save" />`.
- Use the `ErrorMessage` component for field validation errors (`import ErrorMessage from "@/components/ErrorMessage"`): `{errors.name && <ErrorMessage message={errors.name.message} />}`
## Authentication ## Authentication
@ -79,4 +82,6 @@ The client proxies `/api/*` requests to the server via Vite config (target is co
- **Framework**: Playwright - **Framework**: Playwright
- Use the `e2e-test-writer` agent for writing Playwright E2E tests - Use the `e2e-test-writer` agent for writing Playwright E2E tests
- Run with `bun run test:e2e` from root - 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

View file

@ -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 (
<Link
to={to}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
{children}
</Link>
);
}

View file

@ -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 (
<Alert variant="destructive" className={className}>
<AlertCircle className="h-4 w-4" />
<AlertDescription>{text}</AlertDescription>
</Alert>
);
}

View file

@ -0,0 +1,3 @@
export default function ErrorMessage({ message }: { message?: string }) {
return <p className="text-sm text-destructive">{message}</p>;
}

View file

@ -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(<ReplyForm ticket={{ id: TICKET_ID }} />);
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();
});
});

View file

@ -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<CreateReplyInput>({
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 (
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
{mutation.error && (
<ErrorAlert error={mutation.error} fallback="Failed to send reply" />
)}
<div className="space-y-1">
<Textarea
placeholder="Type your reply..."
{...register("body")}
rows={4}
/>
{errors.body && <ErrorMessage message={errors.body.message} />}
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Sending..." : "Send Reply"}
</Button>
</form>
);
}

View file

@ -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(<ReplyThread ticket={mockTicket} />);
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(<ReplyThread ticket={mockTicket} />);
await waitFor(() => {
expect(screen.getByText("No replies yet")).toBeInTheDocument();
});
});
it("should fetch replies for the ticket", async () => {
mockedAxios.get.mockResolvedValue({ data: { replies: [] } });
renderWithQuery(<ReplyThread ticket={mockTicket} />);
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(<ReplyThread ticket={mockTicket} />);
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(<ReplyThread ticket={mockTicket} />);
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(<ReplyThread ticket={mockTicket} />);
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(<ReplyThread ticket={mockTicket} />);
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(<ReplyThread ticket={mockTicket} />);
await waitFor(() => {
expect(screen.getByText("First reply")).toBeInTheDocument();
});
expect(screen.getByText("Second reply")).toBeInTheDocument();
});
});

View file

@ -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 (
<div className="space-y-3">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
);
}
if (error) {
return <ErrorAlert message="Failed to load replies" />;
}
if (!data?.replies.length) {
return <p className="text-sm text-muted-foreground">No replies yet</p>;
}
return (
<div className="space-y-3">
{data.replies.map((reply) => {
const isAgent = reply.senderType === "agent";
const displayName = isAgent
? reply.user?.name ?? "Agent"
: senderName;
return (
<Card key={reply.id}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
{displayName}
</CardTitle>
<CardDescription className="text-xs">
{senderTypeLabel[reply.senderType]} &middot;{" "}
{new Date(reply.createdAt).toLocaleString()}
</CardDescription>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm">{reply.body}</p>
</CardContent>
</Card>
);
})}
</div>
);
}

View file

@ -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(<TicketDetail ticket={mockTicket} />);
expect(
screen.getByRole("heading", { name: "Cannot login to my account" })
).toBeInTheDocument();
});
it("should display sender name and email", () => {
render(<TicketDetail ticket={mockTicket} />);
expect(
screen.getByText(/Alice Smith \(alice@example\.com\)/)
).toBeInTheDocument();
});
it("should display created and updated dates", () => {
render(<TicketDetail ticket={mockTicket} />);
expect(screen.getByText(/Created:/)).toBeInTheDocument();
expect(screen.getByText(/Updated:/)).toBeInTheDocument();
});
it("should render the plain text body when bodyHtml is null", () => {
render(<TicketDetail ticket={mockTicket} />);
expect(screen.getByText("I need help logging in")).toBeInTheDocument();
});
it("should render HTML body when bodyHtml is present", () => {
const htmlTicket: Ticket = {
...mockTicket,
bodyHtml: "<p>Hello <strong>world</strong></p>",
};
render(<TicketDetail ticket={htmlTicket} />);
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(<TicketDetail ticket={mockTicket} />);
expect(screen.getByText("Message")).toBeInTheDocument();
expect(screen.getByText("From Alice Smith")).toBeInTheDocument();
});
});

View file

@ -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 (
<>
<div>
<h1 className="text-2xl font-bold">{ticket.subject}</h1>
<div className="mt-2 space-y-1 text-sm">
<div>
<span className="text-muted-foreground">From: </span>
{ticket.senderName} ({ticket.senderEmail})
</div>
<div>
<span className="text-muted-foreground">Created: </span>
{new Date(ticket.createdAt).toLocaleString()}
</div>
<div>
<span className="text-muted-foreground">Updated: </span>
{new Date(ticket.updatedAt).toLocaleString()}
</div>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Message</CardTitle>
<CardDescription>From {ticket.senderName}</CardDescription>
</CardHeader>
<CardContent>
{ticket.bodyHtml ? (
<div dangerouslySetInnerHTML={{ __html: ticket.bodyHtml }} />
) : (
<p className="whitespace-pre-wrap">{ticket.body}</p>
)}
</CardContent>
</Card>
</>
);
}

View file

@ -0,0 +1,14 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function TicketDetailSkeleton() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-96" />
<div className="flex gap-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
</div>
<Skeleton className="h-40 w-full" />
</div>
);
}

View file

@ -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<string, unknown>) => {
const { data } = await axios.patch(`/api/tickets/${ticket.id}`, body);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ticket", String(ticket.id)] });
},
});
return (
<div className="w-48 space-y-4 text-sm">
<div className="space-y-1">
<span className="text-muted-foreground">Status</span>
<Select
value={ticket.status}
onValueChange={(value) => updateMutation.mutate({ status: value })}
>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ticketStatuses.map((s) => (
<SelectItem key={s} value={s}>
{statusLabel[s]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<span className="text-muted-foreground">Category</span>
<Select
value={ticket.category ?? "none"}
onValueChange={(value) =>
updateMutation.mutate({
category: value === "none" ? null : value,
})
}
>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{ticketCategories.map((c) => (
<SelectItem key={c} value={c}>
{categoryLabel[c]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<span className="text-muted-foreground">Assigned To</span>
<Select
value={ticket.assignedTo?.id ?? "unassigned"}
onValueChange={(value) =>
updateMutation.mutate({
assignedToId: value === "unassigned" ? null : value,
})
}
>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Unassigned</SelectItem>
{agentsData?.agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}

View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View file

@ -125,6 +125,9 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
h2 {
@apply text-lg font-semibold;
}
input:-webkit-autofill, input:-webkit-autofill,
input:-webkit-autofill:hover, input:-webkit-autofill:hover,
input:-webkit-autofill:focus { input:-webkit-autofill:focus {

View file

@ -14,8 +14,9 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert"; import ErrorAlert from "@/components/ErrorAlert";
import { AlertCircle, Loader2 } from "lucide-react"; import ErrorMessage from "@/components/ErrorMessage";
import { Loader2 } from "lucide-react";
const loginSchema = z.object({ const loginSchema = z.object({
email: z.email("Please enter a valid email"), email: z.email("Please enter a valid email"),
@ -73,10 +74,7 @@ export default function LoginPage() {
<CardContent> <CardContent>
<form onSubmit={handleSubmit(onSubmit)} noValidate> <form onSubmit={handleSubmit(onSubmit)} noValidate>
{serverError && ( {serverError && (
<Alert variant="destructive" className="mb-4"> <ErrorAlert message={serverError} className="mb-4" />
<AlertCircle className="h-4 w-4" />
<AlertDescription>{serverError}</AlertDescription>
</Alert>
)} )}
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
@ -88,9 +86,7 @@ export default function LoginPage() {
{...register("email")} {...register("email")}
/> />
{errors.email && ( {errors.email && (
<p className="text-destructive text-sm"> <ErrorMessage message={errors.email.message} />
{errors.email.message}
</p>
)} )}
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@ -102,9 +98,7 @@ export default function LoginPage() {
{...register("password")} {...register("password")}
/> />
{errors.password && ( {errors.password && (
<p className="text-destructive text-sm"> <ErrorMessage message={errors.password.message} />
{errors.password.message}
</p>
)} )}
</div> </div>
<Button type="submit" className="w-full" disabled={isSubmitting}> <Button type="submit" className="w-full" disabled={isSubmitting}>

View file

@ -78,6 +78,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket }); if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents") if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } }); return Promise.resolve({ data: { agents: mockAgents } });
if (url.includes("/replies"))
return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url")); return Promise.reject(new Error("unexpected url"));
}); });
renderPage(); renderPage();
@ -90,7 +92,7 @@ describe("TicketDetailPage", () => {
const comboboxes = screen.getAllByRole("combobox"); const comboboxes = screen.getAllByRole("combobox");
expect(comboboxes[0]).toHaveTextContent("Open"); expect(comboboxes[0]).toHaveTextContent("Open");
expect(comboboxes[1]).toHaveTextContent("Technical Question"); expect(comboboxes[1]).toHaveTextContent("Technical");
expect( expect(
screen.getByText(/Alice Smith \(alice@example\.com\)/) screen.getByText(/Alice Smith \(alice@example\.com\)/)
).toBeInTheDocument(); ).toBeInTheDocument();
@ -124,6 +126,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket }); if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents") if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } }); return Promise.resolve({ data: { agents: mockAgents } });
if (url.includes("/replies"))
return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url")); return Promise.reject(new Error("unexpected url"));
}); });
renderPage(); renderPage();
@ -139,6 +143,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket }); if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents") if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } }); return Promise.resolve({ data: { agents: mockAgents } });
if (url.includes("/replies"))
return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url")); return Promise.reject(new Error("unexpected url"));
}); });
renderPage(); renderPage();
@ -163,6 +169,8 @@ describe("TicketDetailPage", () => {
return Promise.resolve({ data: assignedTicket }); return Promise.resolve({ data: assignedTicket });
if (url === "/api/agents") if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } }); return Promise.resolve({ data: { agents: mockAgents } });
if (url.includes("/replies"))
return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url")); return Promise.reject(new Error("unexpected url"));
}); });
renderPage(); renderPage();
@ -173,8 +181,10 @@ describe("TicketDetailPage", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
const comboboxes = screen.getAllByRole("combobox"); await waitFor(() => {
expect(comboboxes[2]).toHaveTextContent("Jane Doe"); const comboboxes = screen.getAllByRole("combobox");
expect(comboboxes[2]).toHaveTextContent("Jane Doe");
});
}); });
it("should call PATCH with assignedToId when selecting an agent", async () => { 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/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents") if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } }); return Promise.resolve({ data: { agents: mockAgents } });
if (url.includes("/replies"))
return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url")); return Promise.reject(new Error("unexpected url"));
}); });
mockedAxios.patch.mockResolvedValue({ mockedAxios.patch.mockResolvedValue({
@ -223,6 +235,8 @@ describe("TicketDetailPage", () => {
return Promise.resolve({ data: assignedTicket }); return Promise.resolve({ data: assignedTicket });
if (url === "/api/agents") if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } }); return Promise.resolve({ data: { agents: mockAgents } });
if (url.includes("/replies"))
return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url")); return Promise.reject(new Error("unexpected url"));
}); });
mockedAxios.patch.mockResolvedValue({ mockedAxios.patch.mockResolvedValue({
@ -260,6 +274,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket }); if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents") if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } }); return Promise.resolve({ data: { agents: mockAgents } });
if (url.includes("/replies"))
return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url")); return Promise.reject(new Error("unexpected url"));
}); });
renderPage(); renderPage();
@ -288,6 +304,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket }); if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents") if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } }); return Promise.resolve({ data: { agents: mockAgents } });
if (url.includes("/replies"))
return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url")); return Promise.reject(new Error("unexpected url"));
}); });
mockedAxios.patch.mockResolvedValue({ mockedAxios.patch.mockResolvedValue({
@ -323,6 +341,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket }); if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents") if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } }); return Promise.resolve({ data: { agents: mockAgents } });
if (url.includes("/replies"))
return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url")); return Promise.reject(new Error("unexpected url"));
}); });
renderPage(); renderPage();
@ -334,15 +354,15 @@ describe("TicketDetailPage", () => {
}); });
const comboboxes = screen.getAllByRole("combobox"); const comboboxes = screen.getAllByRole("combobox");
expect(comboboxes[1]).toHaveTextContent("Technical Question"); expect(comboboxes[1]).toHaveTextContent("Technical");
await user.click(comboboxes[1]); await user.click(comboboxes[1]);
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole("option", { name: "None" })).toBeInTheDocument(); expect(screen.getByRole("option", { name: "None" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "General Question" })).toBeInTheDocument(); expect(screen.getByRole("option", { name: "General" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Technical Question" })).toBeInTheDocument(); expect(screen.getByRole("option", { name: "Technical" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Refund Request" })).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/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents") if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } }); return Promise.resolve({ data: { agents: mockAgents } });
if (url.includes("/replies"))
return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url")); return Promise.reject(new Error("unexpected url"));
}); });
mockedAxios.patch.mockResolvedValue({ mockedAxios.patch.mockResolvedValue({
@ -369,10 +391,10 @@ describe("TicketDetailPage", () => {
await user.click(comboboxes[1]); await user.click(comboboxes[1]);
await waitFor(() => { 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(() => { await waitFor(() => {
expect(mockedAxios.patch).toHaveBeenCalledWith("/api/tickets/1", { 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/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents") if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } }); return Promise.resolve({ data: { agents: mockAgents } });
if (url.includes("/replies"))
return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url")); return Promise.reject(new Error("unexpected url"));
}); });
mockedAxios.patch.mockResolvedValue({ mockedAxios.patch.mockResolvedValue({
@ -426,6 +450,8 @@ describe("TicketDetailPage", () => {
return Promise.resolve({ data: htmlTicket }); return Promise.resolve({ data: htmlTicket });
if (url === "/api/agents") if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } }); return Promise.resolve({ data: { agents: mockAgents } });
if (url.includes("/replies"))
return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url")); return Promise.reject(new Error("unexpected url"));
}); });
renderPage(); renderPage();
@ -440,6 +466,8 @@ describe("TicketDetailPage", () => {
if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket }); if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket });
if (url === "/api/agents") if (url === "/api/agents")
return Promise.resolve({ data: { agents: mockAgents } }); return Promise.resolve({ data: { agents: mockAgents } });
if (url.includes("/replies"))
return Promise.resolve({ data: { replies: [] } });
return Promise.reject(new Error("unexpected url")); return Promise.reject(new Error("unexpected url"));
}); });
renderPage(); renderPage();

View file

@ -1,220 +1,59 @@
import { useParams, Link } from "react-router"; import { useParams } from "react-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { type TicketStatus, ticketStatuses, statusLabel } from "core/constants/ticket-status.ts"; import { type Ticket } from "core/constants/ticket.ts";
import { type TicketCategory, ticketCategories, categoryLabel } from "core/constants/ticket-category.ts"; import ErrorAlert from "@/components/ErrorAlert";
import { Alert, AlertDescription } from "@/components/ui/alert"; import BackLink from "@/components/BackLink";
import { Skeleton } from "@/components/ui/skeleton"; import TicketDetailSkeleton from "@/components/TicketDetailSkeleton";
import { import TicketDetail from "@/components/TicketDetail";
Select, import UpdateTicket from "@/components/UpdateTicket";
SelectContent, import ReplyThread from "@/components/ReplyThread";
SelectItem, import ReplyForm from "@/components/ReplyForm";
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;
}
export default function TicketDetailPage() { export default function TicketDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const { data: ticket, isLoading, error } = useQuery({ const { data: ticket, isLoading, error } = useQuery({
queryKey: ["ticket", id], queryKey: ["ticket", id],
queryFn: async () => { queryFn: async () => {
const { data } = await axios.get<TicketDetail>(`/api/tickets/${id}`); const { data } = await axios.get<Ticket>(`/api/tickets/${id}`);
return data; 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<string, unknown>) => {
const { data } = await axios.patch<TicketDetail>(
`/api/tickets/${id}`,
body
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ticket", id] });
},
});
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Link <BackLink to="/tickets">Back to tickets</BackLink>
to="/tickets"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to tickets
</Link>
{isLoading && ( {isLoading && <TicketDetailSkeleton />}
<div className="space-y-4">
<Skeleton className="h-8 w-96" />
<div className="flex gap-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
</div>
<Skeleton className="h-40 w-full" />
</div>
)}
{error && ( {error && (
<Alert variant="destructive"> <ErrorAlert
<AlertCircle className="h-4 w-4" /> message={
<AlertDescription> axios.isAxiosError(error) && error.response?.status === 404
{axios.isAxiosError(error) && error.response?.status === 404
? "Ticket not found" ? "Ticket not found"
: "Failed to load ticket"} : "Failed to load ticket"
</AlertDescription> }
</Alert> />
)} )}
{ticket && ( {ticket && (
<div className="grid grid-cols-[1fr_auto] gap-6"> <div className="grid grid-cols-[1fr_auto] gap-6">
<div className="space-y-6"> <div className="space-y-6">
<div> <TicketDetail ticket={ticket} />
<h1 className="text-2xl font-bold">{ticket.subject}</h1>
<div className="mt-2 space-y-1 text-sm"> <div className="space-y-3">
<div> <h2>Replies</h2>
<span className="text-muted-foreground">From: </span> <ReplyThread ticket={ticket} />
{ticket.senderName} ({ticket.senderEmail})
</div>
<div>
<span className="text-muted-foreground">Created: </span>
{new Date(ticket.createdAt).toLocaleString()}
</div>
<div>
<span className="text-muted-foreground">Updated: </span>
{new Date(ticket.updatedAt).toLocaleString()}
</div>
</div>
</div> </div>
<Card> <div className="space-y-3">
<CardHeader> <h2>Add a Reply</h2>
<CardTitle>Message</CardTitle> <ReplyForm ticket={ticket} />
<CardDescription>
From {ticket.senderName}
</CardDescription>
</CardHeader>
<CardContent>
{ticket.bodyHtml ? (
<div
dangerouslySetInnerHTML={{ __html: ticket.bodyHtml }}
/>
) : (
<p className="whitespace-pre-wrap">{ticket.body}</p>
)}
</CardContent>
</Card>
</div>
<div className="w-48 space-y-4 text-sm">
<div className="space-y-1">
<span className="text-muted-foreground">Status</span>
<Select
value={ticket.status}
onValueChange={(value) =>
updateMutation.mutate({ status: value })
}
>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ticketStatuses.map((s) => (
<SelectItem key={s} value={s}>
{statusLabel[s]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<span className="text-muted-foreground">Category</span>
<Select
value={ticket.category ?? "none"}
onValueChange={(value) =>
updateMutation.mutate({
category: value === "none" ? null : value,
})
}
>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{ticketCategories.map((c) => (
<SelectItem key={c} value={c}>
{categoryLabel[c]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<span className="text-muted-foreground">Assigned To</span>
<Select
value={ticket.assignedTo?.id ?? "unassigned"}
onValueChange={(value) =>
updateMutation.mutate({
assignedToId: value === "unassigned" ? null : value,
})
}
>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Unassigned</SelectItem>
{agentsData?.agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
<UpdateTicket ticket={ticket} />
</div> </div>
)} )}
</div> </div>

View file

@ -10,9 +10,9 @@ import {
getCoreRowModel, getCoreRowModel,
flexRender, flexRender,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { type TicketStatus, statusVariant } from "core/constants/ticket-status.ts"; import { type Ticket } from "core/constants/ticket.ts";
import { type TicketCategory } from "core/constants/ticket-category.ts"; import { statusVariant } from "core/constants/ticket-status.ts";
import { Alert, AlertDescription } from "@/components/ui/alert"; import ErrorAlert from "@/components/ErrorAlert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -25,7 +25,6 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {
AlertCircle,
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
ArrowUpDown, ArrowUpDown,
@ -36,16 +35,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { TicketFilters } from "./TicketsPage"; import type { TicketFilters } from "./TicketsPage";
interface Ticket {
id: number;
subject: string;
status: TicketStatus;
category: TicketCategory | null;
senderName: string;
senderEmail: string;
createdAt: string;
}
interface TicketsResponse { interface TicketsResponse {
tickets: Ticket[]; tickets: Ticket[];
total: number; total: number;
@ -165,12 +154,7 @@ export default function TicketsTable({ filters }: { filters: TicketFilters }) {
}); });
if (error) { if (error) {
return ( return <ErrorAlert message="Failed to fetch tickets" />;
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Failed to fetch tickets</AlertDescription>
</Alert>
);
} }
return ( return (

View file

@ -8,11 +8,11 @@ import {
} from "core/schemas/users"; } from "core/schemas/users";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { AlertCircle } from "lucide-react"; import ErrorAlert from "@/components/ErrorAlert";
import ErrorMessage from "@/components/ErrorMessage";
interface UserData { interface UserData {
id: string; 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 ( return (
<form <form
onSubmit={form.handleSubmit((data) => mutation.mutate(data))} onSubmit={form.handleSubmit((data) => mutation.mutate(data))}
@ -77,9 +70,7 @@ export default function UserForm({ user, onSuccess }: UserFormProps) {
{...form.register("name")} {...form.register("name")}
/> />
{form.formState.errors.name && ( {form.formState.errors.name && (
<p className="text-sm text-destructive"> <ErrorMessage message={form.formState.errors.name.message} />
{form.formState.errors.name.message}
</p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -93,9 +84,7 @@ export default function UserForm({ user, onSuccess }: UserFormProps) {
{...form.register("email")} {...form.register("email")}
/> />
{form.formState.errors.email && ( {form.formState.errors.email && (
<p className="text-sm text-destructive"> <ErrorMessage message={form.formState.errors.email.message} />
{form.formState.errors.email.message}
</p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -109,16 +98,14 @@ export default function UserForm({ user, onSuccess }: UserFormProps) {
{...form.register("password")} {...form.register("password")}
/> />
{form.formState.errors.password && ( {form.formState.errors.password && (
<p className="text-sm text-destructive"> <ErrorMessage message={form.formState.errors.password.message} />
{form.formState.errors.password.message}
</p>
)} )}
</div> </div>
{serverError && ( {mutation.error && (
<Alert variant="destructive"> <ErrorAlert
<AlertCircle className="h-4 w-4" /> error={mutation.error}
<AlertDescription>{serverError}</AlertDescription> fallback={`Failed to ${isEdit ? "update" : "create"} user`}
</Alert> />
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" disabled={mutation.isPending}>

View file

@ -18,8 +18,8 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert"; import ErrorAlert from "@/components/ErrorAlert";
import { AlertCircle, Plus } from "lucide-react"; import { Plus } from "lucide-react";
import UserForm from "./UserForm"; import UserForm from "./UserForm";
import UsersTable from "./UsersTable"; import UsersTable from "./UsersTable";
@ -87,10 +87,7 @@ export default function UsersPage() {
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
{deleteMutation.isError && ( {deleteMutation.isError && (
<Alert variant="destructive"> <ErrorAlert message="Failed to delete user" />
<AlertCircle className="h-4 w-4" />
<AlertDescription>Failed to delete user</AlertDescription>
</Alert>
)} )}
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>

View file

@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { Role } from "core/constants/role.ts"; 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -13,7 +13,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { AlertCircle, Pencil, Trash2 } from "lucide-react"; import { Pencil, Trash2 } from "lucide-react";
interface User { interface User {
id: string; id: string;
@ -42,12 +42,7 @@ export default function UsersTable({ onEdit, onDelete }: UsersTableProps) {
}); });
if (error) { if (error) {
return ( return <ErrorAlert message="Failed to fetch users" />;
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Failed to fetch users</AlertDescription>
</Alert>
);
} }
return ( return (

View file

@ -0,0 +1,8 @@
export const senderTypes = ["agent", "customer"] as const;
export type SenderType = (typeof senderTypes)[number];
export const senderTypeLabel: Record<SenderType, string> = {
agent: "Agent",
customer: "Customer",
};

16
core/constants/ticket.ts Normal file
View file

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

7
core/schemas/replies.ts Normal file
View file

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

View file

@ -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<Parameters<typeof test>[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>
): 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");
});
});

View file

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

View file

@ -24,6 +24,11 @@ enum TicketStatus {
closed closed
} }
enum SenderType {
agent
customer
}
enum TicketCategory { enum TicketCategory {
general_question general_question
technical_question technical_question
@ -43,6 +48,7 @@ model User {
sessions Session[] sessions Session[]
accounts Account[] accounts Account[]
assignedTickets Ticket[] assignedTickets Ticket[]
replies Reply[]
@@map("user") @@map("user")
} }
@ -93,10 +99,24 @@ model Ticket {
assignedTo User? @relation(fields: [assignedToId], references: [id]) assignedTo User? @relation(fields: [assignedToId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
replies Reply[]
@@map("ticket") @@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 { model Verification {
id String @id id String @id
identifier String identifier String

View file

@ -9,6 +9,7 @@ import usersRouter from "./routes/users";
import ticketsRouter from "./routes/tickets"; import ticketsRouter from "./routes/tickets";
import agentsRouter from "./routes/agents"; import agentsRouter from "./routes/agents";
import webhooksRouter from "./routes/webhooks"; import webhooksRouter from "./routes/webhooks";
import repliesRouter from "./routes/replies";
if (!process.env.BETTER_AUTH_SECRET) { if (!process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET environment variable is required"); 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/users", usersRouter);
app.use("/api/tickets", ticketsRouter); app.use("/api/tickets", ticketsRouter);
app.use("/api/agents", agentsRouter); app.use("/api/agents", agentsRouter);
app.use("/api/tickets/:ticketId/replies", repliesRouter);
app.use("/api/webhooks", webhooksRouter); app.use("/api/webhooks", webhooksRouter);
if (!process.env.WEBHOOK_SECRET) { if (!process.env.WEBHOOK_SECRET) {

View file

@ -0,0 +1,4 @@
export function parseId(raw: unknown): number | null {
const id = Number(raw);
return Number.isInteger(id) && id > 0 ? id : null;
}

View file

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

View file

@ -1,6 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import { requireAuth } from "../middleware/require-auth"; import { requireAuth } from "../middleware/require-auth";
import { validate } from "../lib/validate"; import { validate } from "../lib/validate";
import { parseId } from "../lib/parse-id";
import { ticketListQuerySchema, updateTicketSchema } from "core/schemas/tickets.ts"; import { ticketListQuerySchema, updateTicketSchema } from "core/schemas/tickets.ts";
import prisma from "../db"; import prisma from "../db";
import type { Prisma } from "../generated/prisma/client"; import type { Prisma } from "../generated/prisma/client";
@ -52,8 +53,8 @@ router.get("/", requireAuth, async (req, res) => {
}); });
router.get("/:id", requireAuth, async (req, res) => { router.get("/:id", requireAuth, async (req, res) => {
const id = Number(req.params.id); const id = parseId(req.params.id);
if (!Number.isInteger(id) || id <= 0) { if (!id) {
res.status(400).json({ error: "Invalid ticket ID" }); res.status(400).json({ error: "Invalid ticket ID" });
return; return;
} }
@ -74,8 +75,8 @@ router.get("/:id", requireAuth, async (req, res) => {
}); });
router.patch("/:id", requireAuth, async (req, res) => { router.patch("/:id", requireAuth, async (req, res) => {
const id = Number(req.params.id); const id = parseId(req.params.id);
if (!Number.isInteger(id) || id <= 0) { if (!id) {
res.status(400).json({ error: "Invalid ticket ID" }); res.status(400).json({ error: "Invalid ticket ID" });
return; return;
} }

View file

@ -26,6 +26,14 @@ router.post("/inbound-email", requireWebhookSecret, async (req, res) => {
}); });
if (existingTicket) { if (existingTicket) {
await prisma.reply.create({
data: {
body: data.body,
senderType: "customer",
ticketId: existingTicket.id,
userId: null,
},
});
res.status(200).json({ ticket: existingTicket }); res.status(200).json({ ticket: existingTicket });
return; return;
} }