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"`)
|
- 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
|
||||||
|
|
|
||||||
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 {
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
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 { 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue