diff --git a/apps/api/src/__tests__/conversations.test.ts b/apps/api/src/__tests__/conversations.test.ts new file mode 100644 index 0000000..1ee4ed7 --- /dev/null +++ b/apps/api/src/__tests__/conversations.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mock data ──────────────────────────────────────────────────────────────── + +const STAFF_ROW = { + id: "staff-uuid-1", + email: "groomer@groombook.com", + name: "Groomer", + role: "groomer" as const, + businessId: "business-uuid-1", + active: true, + userId: null, + oidcSub: null, + isSuperUser: false, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const BUSINESS_SETTINGS = { + id: "business-uuid-1", + businessName: "Test Salon", +}; + +const CONV_1 = { + id: "conv-uuid-1", + businessId: "business-uuid-1", + clientId: "client-uuid-1", + channel: "sms", + externalNumber: "+15551111111", + businessNumber: "+15552222222", + lastMessageAt: new Date("2025-01-10T10:00:00Z"), + status: "active", + createdAt: new Date("2025-01-01T00:00:00Z"), + updatedAt: new Date("2025-01-10T10:00:00Z"), + staffReadAt: null, +}; + +const MSG_INBOUND_1 = { + id: "msg-uuid-1", + conversationId: "conv-uuid-1", + direction: "inbound", + body: "Hello", + status: "delivered", + sentByStaffId: null, + createdAt: new Date("2025-01-10T09:00:00Z"), + deliveredAt: new Date("2025-01-10T09:01:00Z"), +}; + +const MSG_OUTBOUND_1 = { + id: "msg-uuid-2", + conversationId: "conv-uuid-1", + direction: "outbound", + body: "Hi Alice!", + status: "delivered", + sentByStaffId: "staff-uuid-1", + createdAt: new Date("2025-01-10T10:00:00Z"), + deliveredAt: new Date("2025-01-10T10:01:00Z"), +}; + +// ─── Queue-based mock DB ────────────────────────────────────────────────────── + +let selectRows: Record[] = []; +let selectRows2: Record[] = []; +let selectRows3: Record[] = []; +let updatedValues: Record[] = []; +let selectCallCount = 0; + +function resetMock() { + selectRows = []; + selectRows2 = []; + selectRows3 = []; + updatedValues = []; + selectCallCount = 0; +} + +function resetAll() { + resetMock(); + vi.clearAllMocks(); +} + +const mockSendMessage = vi.hoisted(() => vi.fn()); + +vi.mock("@groombook/db", () => { + function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "innerJoin") { + return () => chain; + } + if (prop === "from") { + return (table: unknown) => { + const tableName = (table as { _name?: string })._name; + const rows = tableName === "businessSettings" ? [BUSINESS_SETTINGS] : selectRows; + return makeChainable(rows); + }; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; + } + + 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 clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + const businessSettings = new Proxy( + { _name: "businessSettings" }, + { get: (t, p) => (p === "_name" ? "businessSettings" : { table: "businessSettings", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: unknown) => { + const tableName = (table as { _name?: string })._name; + if (tableName === "businessSettings") return makeChainable([BUSINESS_SETTINGS]); + if (tableName === "messages") { + // Return selectRows3 if it has data (POST re-query), else cycle through selectRows/selectRows2 + if (selectRows3.length > 0) { + return makeChainable(selectRows3); + } + if (selectCallCount === 0 || selectCallCount === 1) { + const rows = selectCallCount === 0 ? selectRows : selectRows2; + selectCallCount++; + return makeChainable(rows); + } + return makeChainable(selectRows); + } + return makeChainable(selectRows); + }, + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => { + updatedValues.push(vals); + return { returning: () => [vals] }; + }, + }), + }), + insert: () => ({ + values: (vals: Record) => { + return { returning: () => [{ ...vals, id: "msg-uuid-new" }] }; + }, + }), + }), + conversations, + messages, + clients, + businessSettings, + eq: vi.fn((a, b) => ({ type: "eq", a, b })), + and: vi.fn((...args) => ({ type: "and", args })), + desc: vi.fn((col) => ({ type: "desc", col })), + lt: vi.fn((a, b) => ({ type: "lt", a, b })), + sql: vi.fn(() => ({ __type: "sql" })), + isNull: vi.fn((col) => ({ type: "isNull", col })), + }; +}); + +vi.mock("../services/messaging/outbound.js", () => ({ + sendMessage: mockSendMessage, +})); + +// ─── App setup ──────────────────────────────────────────────────────────────── + +const { conversationsRouter } = await import("../routes/conversations.js"); + +const app = new Hono(); +app.use("*", async (c, next) => { + // @ts-expect-error — test-only context injection + c.set("staff", STAFF_ROW); + await next(); +}); +app.route("/conversations", conversationsRouter); + +function jsonRequest(method: string, path: string, body?: unknown) { + return app.request(path, { + method, + headers: { "Content-Type": "application/json" }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +beforeEach(() => resetAll()); + +// ─── GET /conversations ─────────────────────────────────────────────────────── + +describe("GET /api/conversations", () => { + it("returns conversations sorted by recency with unread count", async () => { + selectRows = [ + { ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" }, + ]; + selectRows2 = [{ count: "1" }]; + const res = await app.request("/conversations"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.items).toHaveLength(1); + expect(body.items[0]!.id).toBe("conv-uuid-1"); + expect(body.items[0]!.clientName).toBe("Alice"); + }); + + it("supports cursor-based pagination", async () => { + selectRows = []; + const res = await app.request("/conversations?cursor=conv-uuid-1&limit=1"); + expect(res.status).toBe(200); + }); + + it("enforces max limit of 50", async () => { + selectRows = []; + const res = await app.request("/conversations?limit=200"); + expect(res.status).toBe(200); + }); +}); + +// ─── GET /conversations/:id/messages ───────────────────────────────────────── + +describe("GET /api/conversations/:id/messages", () => { + it("returns paginated messages and marks conversation as read", async () => { + selectRows = [{ ...MSG_INBOUND_1 }, { ...MSG_OUTBOUND_1 }]; + const res = await app.request("/conversations/conv-uuid-1/messages"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.items).toHaveLength(2); + expect(body.items[0]!.id).toBe("msg-uuid-1"); + expect(updatedValues.some((u) => u.staffReadAt !== undefined)).toBe(true); + }); + + it("returns 404 when conversation belongs to different business", async () => { + selectRows = []; + const res = await app.request("/conversations/conv-uuid-other/messages"); + expect(res.status).toBe(404); + }); + + it("returns 401 when not authenticated", async () => { + const appNoAuth = new Hono(); + appNoAuth.route("/conversations", conversationsRouter); + const res = await appNoAuth.request("/conversations/conv-uuid-1/messages"); + expect(res.status).toBe(401); + }); +}); + +// ─── POST /conversations/:id/messages ───────────────────────────────────────── + +describe("POST /api/conversations/:id/messages", () => { + beforeEach(() => { + resetMock(); + vi.clearAllMocks(); + selectRows = [{ ...CONV_1, clientName: "Alice", clientPhone: "+15551111111", channel: "sms" }]; + selectRows2 = []; + selectRows3 = [{ id: "msg-uuid-new", conversationId: "conv-uuid-1", direction: "outbound" as const, body: "Hello Alice!", status: "queued" as const, sentByStaffId: "staff-uuid-1", createdAt: new Date(), deliveredAt: null }]; + updatedValues = []; + }); + + it("sends via outbound service and returns 201", async () => { + mockSendMessage.mockResolvedValueOnce({ + messageId: "msg-uuid-new", + providerMessageId: "provider-msg-1", + status: "queued", + suppressed: false, + }); + + const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", { + body: "Hello Alice!", + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.id).toBe("msg-uuid-new"); + }); + + it("returns 409 when client opted out", async () => { + mockSendMessage.mockResolvedValueOnce({ suppressed: true }); + + const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", { + body: "Hello", + }); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/opted out/i); + }); + + it("returns 404 for cross-tenant conversation", async () => { + selectRows = []; + const res = await jsonRequest("POST", "/conversations/conv-uuid-other/messages", { + body: "Hello", + }); + expect(res.status).toBe(404); + }); + + it("rejects empty body", async () => { + const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", { + body: "", + }); + expect(res.status).toBe(400); + }); + + it("rejects body over 1600 chars", async () => { + const res = await jsonRequest("POST", "/conversations/conv-uuid-1/messages", { + body: "a".repeat(1601), + }); + expect(res.status).toBe(400); + }); +}); \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4dfdb8c..b340c96 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -18,6 +18,7 @@ import { groomingLogsRouter } from "./routes/groomingLogs.js"; import { impersonationRouter } from "./routes/impersonation.js"; import { settingsRouter } from "./routes/settings.js"; import { authProviderRouter } from "./routes/authProvider.js"; +import { conversationsRouter } from "./routes/conversations.js"; import { searchRouter } from "./routes/search.js"; import { getObject } from "./lib/s3.js"; import { calendarRouter } from "./routes/calendar.js"; @@ -273,6 +274,7 @@ api.route("/admin/settings", settingsRouter); api.route("/admin/auth-provider", authProviderRouter); api.route("/admin/seed", adminSeedRouter); api.route("/search", searchRouter); +api.route("/conversations", conversationsRouter); const port = Number(process.env.PORT ?? 3000); await initAuth(); diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 906f505..198f55b 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -23,7 +23,8 @@ if (process.env.AUTH_DISABLED === "true") { } export const authMiddleware: MiddlewareHandler = async (c, next) => { - if (c.req.path.startsWith("/api/auth/")) { + const path = c.req.path; + if (path.startsWith("/api/auth/") || path.startsWith("/api/webhooks/")) { await next(); return; } diff --git a/apps/api/src/routes/conversations.ts b/apps/api/src/routes/conversations.ts new file mode 100644 index 0000000..0b8eddb --- /dev/null +++ b/apps/api/src/routes/conversations.ts @@ -0,0 +1,274 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { + and, + eq, + desc, + lt, + sql, + getDb, + conversations, + messages, + clients, + businessSettings, +} from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; +import { sendMessage } from "../services/messaging/outbound.js"; + +export const conversationsRouter = new Hono(); + +const sendMessageSchema = z.object({ + body: z.string().min(1).max(1600), +}); + +// GET /api/conversations — List conversations +conversationsRouter.get("/", async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + if (!staffRow) return c.json({ error: "Unauthorized" }, 401); + + const [settings] = await db + .select({ id: businessSettings.id }) + .from(businessSettings) + .limit(1); + if (!settings) return c.json({ error: "Business not found" }, 404); + + const cursor = c.req.query("cursor") || undefined; + const limit = Math.min(Number(c.req.query("limit") || "20"), 50); + + let baseQuery = db + .select({ + id: conversations.id, + clientId: conversations.clientId, + lastMessageAt: conversations.lastMessageAt, + status: conversations.status, + staffReadAt: conversations.staffReadAt, + clientName: clients.name, + clientPhone: clients.phone, + channel: conversations.channel, + }) + .from(conversations) + .innerJoin(clients, eq(conversations.clientId, clients.id)) + .where(eq(conversations.businessId, settings.id)) + .orderBy(desc(conversations.lastMessageAt)) + .limit(limit + 1); + + if (cursor) { + const [cursorRow] = await db + .select({ lastMessageAt: conversations.lastMessageAt }) + .from(conversations) + .where(eq(conversations.id, cursor)) + .limit(1); + if (cursorRow?.lastMessageAt) { + baseQuery = db + .select({ + id: conversations.id, + clientId: conversations.clientId, + lastMessageAt: conversations.lastMessageAt, + status: conversations.status, + staffReadAt: conversations.staffReadAt, + clientName: clients.name, + clientPhone: clients.phone, + channel: conversations.channel, + }) + .from(conversations) + .innerJoin(clients, eq(conversations.clientId, clients.id)) + .where( + and( + eq(conversations.businessId, settings.id), + lt(conversations.lastMessageAt, cursorRow.lastMessageAt) + ) + ) + .orderBy(desc(conversations.lastMessageAt)) + .limit(limit + 1); + } + } + + const rows = await baseQuery; + + const hasMore = rows.length > limit; + if (hasMore) rows.pop(); + + const items = await Promise.all( + rows.map(async (row) => { + const [unreadRow] = await db + .select({ count: sql`count(*)` }) + .from(messages) + .where( + and( + eq(messages.conversationId, row.id), + eq(messages.direction, "inbound"), + sql`${messages.createdAt} > COALESCE(${row.staffReadAt}, '1970-01-01'::timestamp)` + ) + ) + .limit(1); + + const [lastMsg] = await db + .select({ + body: messages.body, + direction: messages.direction, + createdAt: messages.createdAt, + }) + .from(messages) + .where(eq(messages.conversationId, row.id)) + .orderBy(desc(messages.createdAt)) + .limit(1); + + return { + id: row.id, + clientId: row.clientId, + clientName: row.clientName, + clientPhone: row.clientPhone, + channel: row.channel, + lastMessageAt: row.lastMessageAt, + status: row.status, + unreadCount: Number(unreadRow?.count ?? 0), + lastMessage: lastMsg ?? null, + }; + }) + ); + + const lastRow = rows[rows.length - 1]; + const nextCursor = hasMore && lastRow ? lastRow.id : null; + return c.json({ items, nextCursor }); +}); + +// GET /api/conversations/:id/messages — List messages for a conversation +conversationsRouter.get("/:id/messages", async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + if (!staffRow) return c.json({ error: "Unauthorized" }, 401); + + const conversationId = c.req.param("id"); + const cursor = c.req.query("cursor") || undefined; + const limit = Math.min(Number(c.req.query("limit") || "50"), 100); + + const [settings] = await db + .select({ id: businessSettings.id }) + .from(businessSettings) + .limit(1); + if (!settings) return c.json({ error: "Business not found" }, 404); + + const [conv] = await db + .select({ id: conversations.id }) + .from(conversations) + .where( + and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id)) + ) + .limit(1); + if (!conv) return c.json({ error: "Not found" }, 404); + + await db + .update(conversations) + .set({ staffReadAt: new Date() }) + .where(eq(conversations.id, conversationId)); + + let query = db + .select({ + id: messages.id, + direction: messages.direction, + body: messages.body, + status: messages.status, + sentByStaffId: messages.sentByStaffId, + createdAt: messages.createdAt, + deliveredAt: messages.deliveredAt, + }) + .from(messages) + .where(eq(messages.conversationId, conversationId)) + .orderBy(desc(messages.createdAt)) + .limit(limit + 1); + + if (cursor) { + const [cursorRow] = await db + .select({ createdAt: messages.createdAt }) + .from(messages) + .where(eq(messages.id, cursor)) + .limit(1); + if (cursorRow?.createdAt) { + query = db + .select({ + id: messages.id, + direction: messages.direction, + body: messages.body, + status: messages.status, + sentByStaffId: messages.sentByStaffId, + createdAt: messages.createdAt, + deliveredAt: messages.deliveredAt, + }) + .from(messages) + .where( + and( + eq(messages.conversationId, conversationId), + lt(messages.createdAt, cursorRow.createdAt) + ) + ) + .orderBy(desc(messages.createdAt)) + .limit(limit + 1); + } + } + + const rows = await query; + const hasMore = rows.length > limit; + if (hasMore) rows.pop(); + + const lastRow = rows[rows.length - 1]; + const nextCursor = hasMore && lastRow ? lastRow.id : null; + return c.json({ items: rows, nextCursor }); +}); + +// POST /api/conversations/:id/messages — Send a message +conversationsRouter.post( + "/:id/messages", + zValidator("json", sendMessageSchema), + async (c) => { + const db = getDb(); + const staffRow = c.get("staff"); + if (!staffRow) return c.json({ error: "Unauthorized" }, 401); + + const conversationId = c.req.param("id"); + const { body } = c.req.valid("json"); + + const [settings] = await db + .select({ id: businessSettings.id }) + .from(businessSettings) + .limit(1); + if (!settings) return c.json({ error: "Business not found" }, 404); + + const [conv] = await db + .select({ id: conversations.id, clientId: conversations.clientId }) + .from(conversations) + .where( + and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id)) + ) + .limit(1); + if (!conv) return c.json({ error: "Not found" }, 404); + + const result = await sendMessage({ + businessId: settings.id, + clientId: conv.clientId, + body, + sentByStaffId: staffRow.id, + }); + + if (result.suppressed) { + return c.json({ error: "Client has opted out of SMS" }, 409); + } + + const [msg] = await db + .select({ + id: messages.id, + direction: messages.direction, + body: messages.body, + status: messages.status, + sentByStaffId: messages.sentByStaffId, + createdAt: messages.createdAt, + deliveredAt: messages.deliveredAt, + }) + .from(messages) + .where(eq(messages.id, result.messageId)) + .limit(1); + + return c.json(msg, 201); + } +); \ No newline at end of file diff --git a/packages/db/migrations/0032_staff_read_at.sql b/packages/db/migrations/0032_staff_read_at.sql new file mode 100644 index 0000000..b910ff8 --- /dev/null +++ b/packages/db/migrations/0032_staff_read_at.sql @@ -0,0 +1 @@ +ALTER TABLE "conversations" ADD COLUMN "staff_read_at" timestamp; \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index eef2244..ff0c252 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1775828067192, "tag": "0030_messaging", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1778818472097, + "tag": "0032_staff_read_at", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index f1d74b3..c4e2f1a 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -444,6 +444,7 @@ export const conversations = pgTable( status: text("status").notNull().default("active"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), + staffReadAt: timestamp("staff_read_at"), }, (t) => [ index("idx_conversations_business_id_last_message_at").on(