mirror of
https://github.com/mosh-hamedani/helpdesk.git
synced 2026-05-21 11:58:19 +02:00
Add the ability to respond to tickets
This commit is contained in:
parent
db29af6970
commit
5bf969ae7b
|
|
@ -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: `<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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
19
client/src/components/BackLink.tsx
Normal file
19
client/src/components/BackLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
client/src/components/ErrorAlert.tsx
Normal file
36
client/src/components/ErrorAlert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
client/src/components/ErrorMessage.tsx
Normal file
3
client/src/components/ErrorMessage.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function ErrorMessage({ message }: { message?: string }) {
|
||||
return <p className="text-sm text-destructive">{message}</p>;
|
||||
}
|
||||
130
client/src/components/ReplyForm.test.tsx
Normal file
130
client/src/components/ReplyForm.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
63
client/src/components/ReplyForm.tsx
Normal file
63
client/src/components/ReplyForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
client/src/components/ReplyThread.test.tsx
Normal file
160
client/src/components/ReplyThread.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
77
client/src/components/ReplyThread.tsx
Normal file
77
client/src/components/ReplyThread.tsx
Normal 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]} ·{" "}
|
||||
{new Date(reply.createdAt).toLocaleString()}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap text-sm">{reply.body}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
client/src/components/TicketDetail.test.tsx
Normal file
67
client/src/components/TicketDetail.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
50
client/src/components/TicketDetail.tsx
Normal file
50
client/src/components/TicketDetail.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
client/src/components/TicketDetailSkeleton.tsx
Normal file
14
client/src/components/TicketDetailSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
client/src/components/UpdateTicket.tsx
Normal file
114
client/src/components/UpdateTicket.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
client/src/components/ui/textarea.tsx
Normal file
18
client/src/components/ui/textarea.tsx
Normal 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 }
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||
{serverError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{serverError}</AlertDescription>
|
||||
</Alert>
|
||||
<ErrorAlert message={serverError} className="mb-4" />
|
||||
)}
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -88,9 +86,7 @@ export default function LoginPage() {
|
|||
{...register("email")}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-destructive text-sm">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
<ErrorMessage message={errors.email.message} />
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -102,9 +98,7 @@ export default function LoginPage() {
|
|||
{...register("password")}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-destructive text-sm">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
<ErrorMessage message={errors.password.message} />
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<TicketDetail>(`/api/tickets/${id}`);
|
||||
const { data } = await axios.get<Ticket>(`/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<string, unknown>) => {
|
||||
const { data } = await axios.patch<TicketDetail>(
|
||||
`/api/tickets/${id}`,
|
||||
body
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["ticket", id] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
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>
|
||||
<BackLink to="/tickets">Back to tickets</BackLink>
|
||||
|
||||
{isLoading && (
|
||||
<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>
|
||||
)}
|
||||
{isLoading && <TicketDetailSkeleton />}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{axios.isAxiosError(error) && error.response?.status === 404
|
||||
<ErrorAlert
|
||||
message={
|
||||
axios.isAxiosError(error) && error.response?.status === 404
|
||||
? "Ticket not found"
|
||||
: "Failed to load ticket"}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
: "Failed to load ticket"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{ticket && (
|
||||
<div className="grid grid-cols-[1fr_auto] gap-6">
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
<TicketDetail ticket={ticket} />
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2>Replies</h2>
|
||||
<ReplyThread ticket={ticket} />
|
||||
</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>
|
||||
</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 className="space-y-3">
|
||||
<h2>Add a Reply</h2>
|
||||
<ReplyForm ticket={ticket} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UpdateTicket ticket={ticket} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Failed to fetch tickets</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return <ErrorAlert message="Failed to fetch tickets" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => mutation.mutate(data))}
|
||||
|
|
@ -77,9 +70,7 @@ export default function UserForm({ user, onSuccess }: UserFormProps) {
|
|||
{...form.register("name")}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
<ErrorMessage message={form.formState.errors.name.message} />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -93,9 +84,7 @@ export default function UserForm({ user, onSuccess }: UserFormProps) {
|
|||
{...form.register("email")}
|
||||
/>
|
||||
{form.formState.errors.email && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.email.message}
|
||||
</p>
|
||||
<ErrorMessage message={form.formState.errors.email.message} />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -109,16 +98,14 @@ export default function UserForm({ user, onSuccess }: UserFormProps) {
|
|||
{...form.register("password")}
|
||||
/>
|
||||
{form.formState.errors.password && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.password.message}
|
||||
</p>
|
||||
<ErrorMessage message={form.formState.errors.password.message} />
|
||||
)}
|
||||
</div>
|
||||
{serverError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{serverError}</AlertDescription>
|
||||
</Alert>
|
||||
{mutation.error && (
|
||||
<ErrorAlert
|
||||
error={mutation.error}
|
||||
fallback={`Failed to ${isEdit ? "update" : "create"} user`}
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{deleteMutation.isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Failed to delete user</AlertDescription>
|
||||
</Alert>
|
||||
<ErrorAlert message="Failed to delete user" />
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Failed to fetch users</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return <ErrorAlert message="Failed to fetch users" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
8
core/constants/sender-type.ts
Normal file
8
core/constants/sender-type.ts
Normal 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
16
core/constants/ticket.ts
Normal 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
7
core/schemas/replies.ts
Normal 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>;
|
||||
238
e2e/tests/ticket-detail.spec.ts
Normal file
238
e2e/tests/ticket-detail.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
4
server/src/lib/parse-id.ts
Normal file
4
server/src/lib/parse-id.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export function parseId(raw: unknown): number | null {
|
||||
const id = Number(raw);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
61
server/src/routes/replies.ts
Normal file
61
server/src/routes/replies.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue