diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1f9bc2a..eb85601 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -19,7 +19,6 @@ 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"; diff --git a/apps/api/src/routes/conversations.ts b/apps/api/src/routes/conversations.ts index 7b0cb4f..0b8eddb 100644 --- a/apps/api/src/routes/conversations.ts +++ b/apps/api/src/routes/conversations.ts @@ -1,214 +1,273 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, desc, lt, isNull, sql, count } from "@groombook/db"; -import { getDb, conversations, messages, clients } from "@groombook/db"; -import { resolveStaffMiddleware } from "../middleware/rbac.js"; +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(); -conversationsRouter.use("/*", resolveStaffMiddleware); +const sendMessageSchema = z.object({ + body: z.string().min(1).max(1600), +}); -// GET /api/conversations — list all conversations for staff's business +// GET /api/conversations — List conversations conversationsRouter.get("/", async (c) => { const db = getDb(); - const businessId = c.get("staff").businessId; + const staffRow = c.get("staff"); + if (!staffRow) return c.json({ error: "Unauthorized" }, 401); - const rows = await db + 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, - businessId: conversations.businessId, clientId: conversations.clientId, - channel: conversations.channel, - externalNumber: conversations.externalNumber, - businessNumber: conversations.businessNumber, lastMessageAt: conversations.lastMessageAt, status: conversations.status, - createdAt: conversations.createdAt, staffReadAt: conversations.staffReadAt, + clientName: clients.name, + clientPhone: clients.phone, + channel: conversations.channel, }) .from(conversations) - .where(eq(conversations.businessId, businessId)) + .innerJoin(clients, eq(conversations.clientId, clients.id)) + .where(eq(conversations.businessId, settings.id)) .orderBy(desc(conversations.lastMessageAt)) - .limit(20); + .limit(limit + 1); - // For each conversation, fetch client name and count unread messages - const enriched = await Promise.all( + 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 [client] = await db - .select({ name: clients.name }) - .from(clients) - .where(eq(clients.id, row.clientId)) - .limit(1); - - // Count messages where direction = 'inbound' AND readByClientAt IS NULL - const [{ count: unreadCount }] = await db - .select({ count: count() }) + const [unreadRow] = await db + .select({ count: sql`count(*)` }) .from(messages) .where( and( eq(messages.conversationId, row.id), eq(messages.direction, "inbound"), - isNull(messages.readByClientAt) + sql`${messages.createdAt} > COALESCE(${row.staffReadAt}, '1970-01-01'::timestamp)` ) - ); + ) + .limit(1); - // Fetch last message body for preview const [lastMsg] = await db - .select({ body: messages.body, createdAt: messages.createdAt }) + .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 { - ...row, - clientName: client?.name ?? "Unknown", - lastMessageBody: lastMsg?.body ?? null, - unreadCount: Number(unreadCount), + 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, }; }) ); - return c.json(enriched); + const lastRow = rows[rows.length - 1]; + const nextCursor = hasMore && lastRow ? lastRow.id : null; + return c.json({ items, nextCursor }); }); -// GET /api/conversations/:id — get a single conversation -conversationsRouter.get("/:id", async (c) => { - const db = getDb(); - const businessId = c.get("staff").businessId; - const conversationId = c.req.param("id"); - - const [row] = await db - .select() - .from(conversations) - .where(and(eq(conversations.id, conversationId), eq(conversations.businessId, businessId))) - .limit(1); - - if (!row) { - return c.json({ error: "Not found" }, 404); - } - - const [client] = await db - .select({ name: clients.name }) - .from(clients) - .where(eq(clients.id, row.clientId)) - .limit(1); - - return c.json({ ...row, clientName: client?.name ?? "Unknown" }); -}); - -// GET /api/conversations/:id/messages — get messages for a conversation +// GET /api/conversations/:id/messages — List messages for a conversation conversationsRouter.get("/:id/messages", async (c) => { const db = getDb(); - const businessId = c.get("staff").businessId; - const conversationId = c.req.param("id"); - const limit = parseInt(c.req.query("limit") ?? "50", 10); - const cursor = c.req.query("cursor"); + const staffRow = c.get("staff"); + if (!staffRow) return c.json({ error: "Unauthorized" }, 401); - // Verify staff owns this conversation - const [conversation] = await db + 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, businessId))) + .where( + and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id)) + ) .limit(1); + if (!conv) return c.json({ error: "Not found" }, 404); - if (!conversation) { - return c.json({ error: "Not found" }, 404); - } - - // Mark conversation as read by staff 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 [cursorMsg] = await db + const [cursorRow] = await db .select({ createdAt: messages.createdAt }) .from(messages) .where(eq(messages.id, cursor)) .limit(1); - - if (cursorMsg) { - const rows = await db - .select() + 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, cursorMsg.createdAt))) + .where( + and( + eq(messages.conversationId, conversationId), + lt(messages.createdAt, cursorRow.createdAt) + ) + ) .orderBy(desc(messages.createdAt)) - .limit(limit); - - return c.json({ messages: rows.reverse(), nextCursor: rows.length === limit ? rows[0]?.id : null }); + .limit(limit + 1); } } - const rows = await db - .select() - .from(messages) - .where(eq(messages.conversationId, conversationId)) - .orderBy(desc(messages.createdAt)) - .limit(limit); + const rows = await query; + const hasMore = rows.length > limit; + if (hasMore) rows.pop(); - return c.json({ messages: rows.reverse(), nextCursor: null }); -}); - -// POST /api/conversations/:id/messages — send a message -const sendMessageSchema = z.object({ - body: z.string().min(1).max(1600), + 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 businessId = c.get("staff").businessId; 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"); - // Verify staff owns this conversation - const [conversation] = await db - .select() + 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, businessId))) + .where( + and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id)) + ) .limit(1); + if (!conv) return c.json({ error: "Not found" }, 404); - if (!conversation) { - return c.json({ error: "Not found" }, 404); - } + const result = await sendMessage({ + businessId: settings.id, + clientId: conv.clientId, + body, + sentByStaffId: staffRow.id, + }); - // Check if client has opted out - const [client] = await db - .select({ optedOutAt: clients.optedOutAt }) - .from(clients) - .where(eq(clients.id, conversation.clientId)) - .limit(1); - - if (client?.optedOutAt) { + if (result.suppressed) { return c.json({ error: "Client has opted out of SMS" }, 409); } - // Create outbound message const [msg] = await db - .insert(messages) - .values({ - conversationId, - direction: "outbound", - body, - status: "queued", - sentByStaffId: staffRow.id, + .select({ + id: messages.id, + direction: messages.direction, + body: messages.body, + status: messages.status, + sentByStaffId: messages.sentByStaffId, + createdAt: messages.createdAt, + deliveredAt: messages.deliveredAt, }) - .returning(); - - // Update conversation lastMessageAt - await db - .update(conversations) - .set({ lastMessageAt: new Date() }) - .where(eq(conversations.id, conversationId)); - - // TODO: Enqueue Telnyx outbound job + .from(messages) + .where(eq(messages.id, result.messageId)) + .limit(1); return c.json(msg, 201); } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 8b3b01f..346942c 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -4,7 +4,7 @@ import * as schema from "./schema.js"; export * from "./schema.js"; export { encryptSecret, decryptSecret } from "./crypto.js"; -export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm"; +export { and, asc, count, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm"; let _db: ReturnType | null = null;