From e605e1be7490d7daf7b1b12e323f1888cf0936a7 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 13:02:47 +0000 Subject: [PATCH 1/2] fix(GRO-1242): align Messages frontend with conversations API contract - Extract Conversation interface fields to match API response: replace lastMessageBody with lastMessage object, externalNumber with clientPhone, remove staffReadAt - loadConversations(): extract json.items array instead of raw array - loadMessages(): extract json.items and reverse() for chronological order - Update test mocks to use { items, nextCursor } response shape Co-Authored-By: Claude Opus 4.7 --- apps/web/src/App.tsx | 3 + apps/web/src/__tests__/Messages.test.tsx | 151 +++++++++++++ apps/web/src/pages/Messages.tsx | 275 +++++++++++++++++++++++ 3 files changed, 429 insertions(+) create mode 100644 apps/web/src/__tests__/Messages.test.tsx create mode 100644 apps/web/src/pages/Messages.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ea51314..f7b42c6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { AppointmentsPage } from "./pages/Appointments.js"; import { ClientsPage } from "./pages/Clients.js"; import { ClientDetailPage } from "./pages/ClientDetailPage.js"; import { ServicesPage } from "./pages/Services.js"; +import { MessagesPage } from "./pages/Messages.js"; import { StaffPage } from "./pages/Staff.js"; import { InvoicesPage } from "./pages/Invoices.js"; import { BookPage } from "./pages/Book.js"; @@ -170,6 +171,7 @@ function LoginPage() { const NAV_LINKS = [ { to: "/admin", label: "Appointments" }, + { to: "/admin/messages", label: "Messages" }, { to: "/admin/clients", label: "Clients" }, { to: "/admin/services", label: "Services" }, { to: "/admin/staff", label: "Staff" }, @@ -296,6 +298,7 @@ function AdminLayout() {
} /> + } /> } /> } /> } /> diff --git a/apps/web/src/__tests__/Messages.test.tsx b/apps/web/src/__tests__/Messages.test.tsx new file mode 100644 index 0000000..b432e7d --- /dev/null +++ b/apps/web/src/__tests__/Messages.test.tsx @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MessagesPage } from "../pages/Messages.js"; + +const mockConversations = [ + { + id: "conv-1", + clientId: "client-1", + clientName: "Alice Smith", + channel: "sms", + clientPhone: "+1234567890", + lastMessageAt: "2026-05-14T10:00:00Z", + lastMessage: { body: "Hello, is my dog ready?", direction: "inbound", createdAt: "2026-05-14T10:00:00Z" }, + unreadCount: 2, + status: "active", + }, + { + id: "conv-2", + clientId: "client-2", + clientName: "Bob Jones", + channel: "sms", + clientPhone: "+1987654321", + lastMessageAt: "2026-05-13T08:00:00Z", + lastMessage: { body: "Thanks for the update", direction: "outbound", createdAt: "2026-05-13T08:05:00Z" }, + unreadCount: 0, + status: "active", + }, +]; + +const mockMessages = [ + { + id: "msg-1", + direction: "inbound" as const, + body: "Hello, is my dog ready?", + status: "delivered", + createdAt: "2026-05-14T10:00:00Z", + sentByStaffId: null, + }, + { + id: "msg-2", + direction: "outbound" as const, + body: "Yes, she is all done!", + status: "delivered", + createdAt: "2026-05-14T10:05:00Z", + sentByStaffId: "staff-1", + }, +]; + +const makeResponse = (data: unknown): Response => { + return { + ok: true, + json: () => Promise.resolve(data), + } as Response; +}; + +const makeResponseWithStatus = (data: unknown, status: number): Response => { + return { + ok: true, + status, + json: () => Promise.resolve(data), + } as Response; +}; + +beforeEach(() => { + global.fetch = vi.fn(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("MessagesPage", () => { + it("renders empty state when no conversations", async () => { + vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: [], nextCursor: null })); + + render(); + await waitFor(() => { + expect(screen.getByText("No conversations yet")).toBeInTheDocument(); + }); + }); + + it("renders conversation list", async () => { + vi.mocked(global.fetch).mockResolvedValue(makeResponse({ items: mockConversations, nextCursor: null })); + + render(); + await waitFor(() => { + expect(screen.getByText("Alice Smith")).toBeInTheDocument(); + expect(screen.getByText("Bob Jones")).toBeInTheDocument(); + }); + + const unreadBadges = screen.getAllByText("2"); + expect(unreadBadges).toHaveLength(1); + }); + + it("loads and displays messages when thread is selected", async () => { + vi.mocked(global.fetch).mockImplementation((input) => { + const url = String(input); + if (url === "/api/conversations?limit=20") { + return Promise.resolve(makeResponse(mockConversations)); + } + if (url === "/api/conversations/conv-1/messages?limit=50") { + return Promise.resolve(makeResponse({ items: mockMessages, nextCursor: null })); + } + return Promise.resolve(makeResponseWithStatus(null, 404)); + }); + + render(); + + await waitFor(() => screen.getByText("Alice Smith")); + fireEvent.click(screen.getByText("Alice Smith")); + + await waitFor(() => { + expect(screen.getByText("Hello, is my dog ready?")).toBeInTheDocument(); + expect(screen.getByText("Yes, she is all done!")).toBeInTheDocument(); + }); + }); + + it("sends a message on form submit", async () => { + let capturedBody: unknown = null; + vi.mocked(global.fetch).mockImplementation((input, init) => { + const url = String(input); + if (url.includes("/messages") && init?.method === "POST") { + capturedBody = init?.body; + return Promise.resolve(makeResponseWithStatus({ + id: "msg-new", + direction: "outbound", + body: "Test message", + status: "queued", + createdAt: new Date().toISOString(), + sentByStaffId: "staff-1", + }, 201)); + } + return Promise.resolve(makeResponse(mockConversations)); + }); + + render(); + + await waitFor(() => screen.getByText("Alice Smith")); + fireEvent.click(screen.getByText("Alice Smith")); + + await waitFor(() => screen.getByPlaceholderText("Type a message…")); + fireEvent.change(screen.getByPlaceholderText("Type a message…"), { + target: { value: "Test message" }, + }); + fireEvent.click(screen.getByText("Send")); + + await waitFor(() => { + expect(capturedBody).toBe('{"body":"Test message"}'); + }); + }); +}); diff --git a/apps/web/src/pages/Messages.tsx b/apps/web/src/pages/Messages.tsx new file mode 100644 index 0000000..df73f73 --- /dev/null +++ b/apps/web/src/pages/Messages.tsx @@ -0,0 +1,275 @@ +import { useEffect, useState, useRef } from "react"; + +interface Conversation { + id: string; + clientId: string; + clientName: string; + channel: string; + clientPhone: string; + lastMessageAt: string | null; + unreadCount: number; + status: string; + lastMessage: { body: string | null; direction: string; createdAt: string } | null; +} + +interface Message { + id: string; + direction: "inbound" | "outbound"; + body: string | null; + status: string; + createdAt: string; + sentByStaffId: string | null; +} + +function relativeTime(dateStr: string | null): string { + if (!dateStr) return ""; + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function truncate(text: string | null, max: number): string { + if (!text) return ""; + return text.length > max ? text.slice(0, max) + "…" : text; +} + +export function MessagesPage() { + const [conversations, setConversations] = useState([]); + const [messages, setMessages] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [loading, setLoading] = useState(true); + const [messagesLoading, setMessagesLoading] = useState(false); + const [error, setError] = useState(null); + const [messageError, setMessageError] = useState(null); + const [body, setBody] = useState(""); + const [sending, setSending] = useState(false); + const messagesEndRef = useRef(null); + + async function loadConversations() { + try { + const res = await fetch("/api/conversations?limit=20"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + const data = json.items as Conversation[]; + setConversations(data); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to load conversations"); + } + } + + async function loadMessages(conversationId: string) { + setMessagesLoading(true); + setMessageError(null); + try { + const res = await fetch(`/api/conversations/${conversationId}/messages?limit=50`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + setMessages((json.items as Message[]).reverse()); + } catch (e: unknown) { + setMessageError(e instanceof Error ? e.message : "Failed to load messages"); + } finally { + setMessagesLoading(false); + } + } + + useEffect(() => { + loadConversations().finally(() => setLoading(false)); + const interval = setInterval(loadConversations, 10000); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (selectedId) { + loadMessages(selectedId); + } else { + setMessages([]); + } + }, [selectedId]); + + useEffect(() => { + if (messages.length > 0) { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [messages]); + + async function handleSend(e: React.FormEvent) { + e.preventDefault(); + if (!selectedId || !body.trim() || sending) return; + setSending(true); + setMessageError(null); + + const optimistic: Message = { + id: `temp-${Date.now()}`, + direction: "outbound", + body: body.trim(), + status: "queued", + createdAt: new Date().toISOString(), + sentByStaffId: null, + }; + + setMessages((prev) => [...prev, optimistic]); + const currentBody = body; + setBody(""); + + try { + const res = await fetch(`/api/conversations/${selectedId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: currentBody }), + }); + + if (res.status === 409) { + const data = (await res.json()) as { error?: string }; + setMessageError(data.error ?? "Client has opted out of SMS"); + setMessages((prev) => prev.filter((m) => m.id !== optimistic.id)); + return; + } + + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + throw new Error(data.error ?? `HTTP ${res.status}`); + } + + const sent = (await res.json()) as Message; + setMessages((prev) => prev.map((m) => (m.id === optimistic.id ? sent : m))); + loadConversations(); + } catch (e: unknown) { + setMessageError(e instanceof Error ? e.message : "Failed to send message"); + setMessages((prev) => prev.filter((m) => m.id !== optimistic.id)); + } finally { + setSending(false); + } + } + + return ( +
+ {/* Thread list */} +
+
+ Conversations +
+ {loading ? ( +

Loading…

+ ) : error ? ( +

{error}

+ ) : conversations.length === 0 ? ( +

No conversations yet

+ ) : ( + conversations.map((conv) => ( +
setSelectedId(conv.id)} + style={{ + padding: "0.75rem 1rem", + borderBottom: "1px solid #f3f4f6", + cursor: "pointer", + background: selectedId === conv.id ? "#ecfdf5" : "transparent", + }} + > +
+ {conv.clientName} + {conv.unreadCount > 0 && ( + + {conv.unreadCount} + + )} +
+
+ {truncate(conv.lastMessage?.body ?? null, 60)} +
+
+ {relativeTime(conv.lastMessageAt)} +
+
+ )) + )} +
+ + {/* Conversation view */} +
+ {!selectedId ? ( +
+ Select a conversation +
+ ) : messagesLoading ? ( +
+ Loading messages… +
+ ) : ( + <> +
+ {messages.map((msg) => ( +
+
+ {msg.body} +
+ + {new Date(msg.createdAt).toLocaleString()} + +
+ ))} +
+
+ + {messageError && ( +
+ {messageError} +
+ )} + +
+ setBody(e.target.value)} + placeholder="Type a message…" + disabled={sending} + style={{ flex: 1, padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }} + /> + +
+ + )} +
+
+ ); +} From 9d9d7da13d794abd9433209beb886829535e2544 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 16:12:31 +0000 Subject: [PATCH 2/2] fix(GRO-985): fix Messages test mocks and scrollIntoView guard - Wrap conversation mocks in { items, nextCursor } response shape (loadConversations reads json.items, bare array caused undefined.length crash) - Guard scrollIntoView with ?. (jsdom doesn't implement it) - Use getAllByText for text appearing in both preview and thread Co-Authored-By: Paperclip --- apps/web/src/__tests__/Messages.test.tsx | 6 +++--- apps/web/src/pages/Messages.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/__tests__/Messages.test.tsx b/apps/web/src/__tests__/Messages.test.tsx index b432e7d..c37db80 100644 --- a/apps/web/src/__tests__/Messages.test.tsx +++ b/apps/web/src/__tests__/Messages.test.tsx @@ -96,7 +96,7 @@ describe("MessagesPage", () => { vi.mocked(global.fetch).mockImplementation((input) => { const url = String(input); if (url === "/api/conversations?limit=20") { - return Promise.resolve(makeResponse(mockConversations)); + return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null })); } if (url === "/api/conversations/conv-1/messages?limit=50") { return Promise.resolve(makeResponse({ items: mockMessages, nextCursor: null })); @@ -110,7 +110,7 @@ describe("MessagesPage", () => { fireEvent.click(screen.getByText("Alice Smith")); await waitFor(() => { - expect(screen.getByText("Hello, is my dog ready?")).toBeInTheDocument(); + expect(screen.getAllByText("Hello, is my dog ready?").length).toBeGreaterThanOrEqual(1); expect(screen.getByText("Yes, she is all done!")).toBeInTheDocument(); }); }); @@ -130,7 +130,7 @@ describe("MessagesPage", () => { sentByStaffId: "staff-1", }, 201)); } - return Promise.resolve(makeResponse(mockConversations)); + return Promise.resolve(makeResponse({ items: mockConversations, nextCursor: null })); }); render(); diff --git a/apps/web/src/pages/Messages.tsx b/apps/web/src/pages/Messages.tsx index df73f73..59f648e 100644 --- a/apps/web/src/pages/Messages.tsx +++ b/apps/web/src/pages/Messages.tsx @@ -93,7 +93,7 @@ export function MessagesPage() { useEffect(() => { if (messages.length > 0) { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + messagesEndRef.current?.scrollIntoView?.({ behavior: "smooth" }); } }, [messages]);