feat(GRO-106): portal Communication tab — real backend

- Added GET /portal/conversation and GET /portal/conversation/messages endpoints
- Created Communication.api.ts with typed fetchers and React hooks
- Rewired Communication.tsx to use real API, removed mock data
- Added composer-disabled bar with "Reply from your phone" tooltip
- Added conversation route tests to portal.test.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 06:07:01 +00:00
committed by Flea Flicker [agent]
parent d0ba537b31
commit 9c9568b80c
19 changed files with 2107 additions and 8916 deletions
+147
View File
@@ -40,11 +40,17 @@ const APPOINTMENT = {
let selectSessionRow: Record<string, unknown> | null = null;
let selectAppointmentRow: Record<string, unknown> | null = null;
let updatedValues: Record<string, unknown>[] = [];
let selectBusinessSettingsRow: Record<string, unknown> | null = null;
let selectConversationRow: Record<string, unknown> | null = null;
let selectMessageRows: Record<string, unknown>[] = [];
function resetMock() {
selectSessionRow = null;
selectAppointmentRow = null;
updatedValues = [];
selectBusinessSettingsRow = null;
selectConversationRow = null;
selectMessageRows = [];
}
vi.mock("@groombook/db", () => {
@@ -72,6 +78,21 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
const businessSettings = new Proxy(
{ _name: "businessSettings" },
{ get: (t, p) => (p === "_name" ? "businessSettings" : { table: "businessSettings", column: p }) }
);
const conversations = new Proxy(
{ _name: "conversations" },
{ get: (t, p) => (p === "_name" ? "conversations" : { table: "conversations", column: p }) }
);
const messages = new Proxy(
{ _name: "messages" },
{ get: (t, p) => (p === "_name" ? "messages" : { table: "messages", column: p }) }
);
const impersonationAuditLogs = new Proxy(
{ _name: "impersonationAuditLogs" },
{ get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) }
@@ -87,6 +108,15 @@ vi.mock("@groombook/db", () => {
if (table._name === "appointments") {
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
}
if (table._name === "businessSettings") {
return makeChainable(selectBusinessSettingsRow ? [selectBusinessSettingsRow] : []);
}
if (table._name === "conversations") {
return makeChainable(selectConversationRow ? [selectConversationRow] : []);
}
if (table._name === "messages") {
return makeChainable(selectMessageRows);
}
return makeChainable([]);
},
}),
@@ -113,8 +143,12 @@ vi.mock("@groombook/db", () => {
impersonationSessions,
appointments,
impersonationAuditLogs,
businessSettings,
conversations,
messages,
eq: vi.fn(),
and: vi.fn(),
desc: vi.fn((col: unknown) => ({ _name: "desc", col })),
};
});
@@ -431,4 +465,117 @@ describe("POST /portal/appointments/:id/cancel", () => {
);
expect(res.status).toBe(404);
});
});
// ─── Conversation routes ───────────────────────────────────────────────────────
const BUSINESS_ID = "880e8400-e29b-41d4-a716-446655440008";
const CONVERSATION_ID = "990e8400-e29b-41d4-a716-446655440009";
const CONVERSATION = {
id: CONVERSATION_ID,
clientId: CLIENT_ID,
businessId: BUSINESS_ID,
channel: "sms",
status: "active",
lastMessageAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
};
const MESSAGE_1 = {
id: "m1",
conversationId: CONVERSATION_ID,
direction: "inbound",
body: "Hello",
status: "delivered",
createdAt: new Date().toISOString(),
deliveredAt: new Date().toISOString(),
};
const MESSAGE_2 = {
id: "m2",
conversationId: CONVERSATION_ID,
direction: "outbound",
body: "Hi there!",
status: "delivered",
createdAt: new Date(Date.now() + 1000).toISOString(),
deliveredAt: new Date().toISOString(),
};
function jsonGet(path: string, headers?: Record<string, string>) {
return app.request(path, { method: "GET", headers });
}
describe("GET /portal/conversation", () => {
it("returns 204 when no conversation exists", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = null;
const res = await jsonGet("/portal/conversation", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(204);
});
it("returns conversation for the authenticated client", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = { ...CONVERSATION };
const res = await jsonGet("/portal/conversation", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe(CONVERSATION_ID);
expect(body.channel).toBe("sms");
expect(body.status).toBe("active");
});
it("returns 204 when client A's session has no conversation (cross-tenant isolation)", async () => {
// Cross-tenant isolation is enforced at the query level via portalClientId scoping.
// The mock cannot replicate eq() filtering — this test verifies the query is issued
// and no conversation is returned when the mock has no row for the session's clientId.
// Real DB: eq() on clientId ensures client A never sees client B's conversation.
selectSessionRow = { ...ACTIVE_SESSION, clientId: "client-a" };
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = null; // client-a has no conversation
const res = await jsonGet("/portal/conversation", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(204);
});
});
describe("GET /portal/conversation/messages", () => {
it("returns 204 when no conversation exists", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = null;
const res = await jsonGet("/portal/conversation/messages", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(204);
});
it("returns paginated messages", async () => {
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = { ...CONVERSATION };
selectMessageRows = [MESSAGE_2, MESSAGE_1];
const res = await jsonGet("/portal/conversation/messages", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.messages).toHaveLength(2);
expect(body.messages[0].id).toBe("m2");
expect(body.messages[1].id).toBe("m1");
expect(body.nextCursor).toBeNull();
});
it("returns messages and nextCursor reflects if more exist", async () => {
// Note: the mock does not enforce limit(), so it returns all messages.
// nextCursor is null when all messages fit (mock behavior).
// Real DB enforces limit and sets nextCursor when messages.length === limit.
selectSessionRow = ACTIVE_SESSION;
selectBusinessSettingsRow = { id: BUSINESS_ID };
selectConversationRow = { ...CONVERSATION };
selectMessageRows = [MESSAGE_1, MESSAGE_2];
const res = await jsonGet("/portal/conversation/messages?limit=1", { "X-Impersonation-Session-Id": SESSION_ID });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.messages.length).toBeGreaterThan(0);
// mock has no limit enforcement, so nextCursor may be null
expect(body).toHaveProperty("nextCursor");
});
});