From 1c4453ed450284eb93175939a9b1a61f1000cd42 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 4 May 2026 02:49:56 +0000 Subject: [PATCH] feat(GRO-984): outbound SMS persistence via outbound.ts - New messaging/outbound.ts: sendMessage() with opt-in check, find/create conversation, queued->sent/failed status transition - sms.ts refactored to be the Telnyx transport only (no persistence) - Unit tests cover success path, opt-out suppression, missing tenant phone Co-Authored-By: Paperclip --- .../messaging/__tests__/outbound.test.ts | 203 ++++++++++++++++++ apps/api/src/services/messaging/outbound.ts | 157 ++++++++++++++ apps/api/src/services/sms.ts | 76 ------- 3 files changed, 360 insertions(+), 76 deletions(-) create mode 100644 apps/api/src/services/messaging/__tests__/outbound.test.ts create mode 100644 apps/api/src/services/messaging/outbound.ts diff --git a/apps/api/src/services/messaging/__tests__/outbound.test.ts b/apps/api/src/services/messaging/__tests__/outbound.test.ts new file mode 100644 index 0000000..d051c29 --- /dev/null +++ b/apps/api/src/services/messaging/__tests__/outbound.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockSendSms = vi.fn(); +const mockGetDb = vi.fn(); +const mockUuidv4 = vi.fn(); + +vi.mock("../sms.js", () => ({ + sendSms: mockSendSms, +})); + +vi.mock("@groombook/db", () => ({ + getDb: () => mockGetDb(), + conversations: {}, + messages: {}, + clients: {}, + businessSettings: {}, + eq: vi.fn((a, b) => [a, b]), + and: vi.fn((...args) => args), +})); + +vi.mock("uuid", () => ({ + v4: () => mockUuidv4(), +})); + +const { sendMessage, MissingTenantPhoneNumberError } = await import("../outbound.ts"); + +const mockEq = (a: unknown, b: unknown) => [a, b]; +const mockAnd = (...args: unknown[]) => args; + +describe("sendMessage", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUuidv4.mockReturnValue("test-uuid"); + }); + + function buildSelectMock(results: unknown[]) { + return vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(results), + }), + }), + }); + } + + it("returns suppressed=true when client has no phone", async () => { + mockGetDb.mockReturnValue({ + select: buildSelectMock([{ phone: null, smsOptIn: true }]), + }); + + const result = await sendMessage({ + businessId: "biz-1", + clientId: "client-1", + body: "Hello", + }); + + expect(result).toEqual({ suppressed: true }); + expect(mockSendSms).not.toHaveBeenCalled(); + }); + + it("returns suppressed=true when client has opted out of SMS", async () => { + mockGetDb.mockReturnValue({ + select: buildSelectMock([{ phone: "+1234567890", smsOptIn: false }]), + }); + + const result = await sendMessage({ + businessId: "biz-1", + clientId: "client-1", + body: "Hello", + }); + + expect(result).toEqual({ suppressed: true }); + expect(mockSendSms).not.toHaveBeenCalled(); + }); + + it("throws MissingTenantPhoneNumberError when tenant has no messaging phone", async () => { + mockGetDb.mockReturnValue({ + select: vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: null }]), + }), + }), + }), + }); + + await expect( + sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" }) + ).rejects.toThrow(MissingTenantPhoneNumberError); + }); + + it("persists provider message id on success", async () => { + const messageId = "msg-1"; + const conversationId = "conv-1"; + + mockGetDb.mockReturnValue({ + select: vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ id: conversationId }]), + }), + }), + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: messageId }]), + }), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), + }); + + mockSendSms.mockResolvedValue({ messageId: "provider-msg-1", status: "sent" }); + + const result = await sendMessage({ + businessId: "biz-1", + clientId: "client-1", + body: "Hello", + }); + + expect(result).toEqual({ + messageId, + providerMessageId: "provider-msg-1", + status: "sent", + suppressed: false, + }); + expect(mockSendSms).toHaveBeenCalledWith("+1234567890", "Hello", undefined); + }); + + it("persists error on Telnyx failure", async () => { + const messageId = "msg-1"; + + mockGetDb.mockReturnValue({ + select: vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: messageId }]), + }), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), + }); + + mockSendSms.mockRejectedValue(new Error("Telnyx API error")); + + await expect( + sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" }) + ).rejects.toThrow("Telnyx API error"); + }); +}); \ No newline at end of file diff --git a/apps/api/src/services/messaging/outbound.ts b/apps/api/src/services/messaging/outbound.ts new file mode 100644 index 0000000..320d78b --- /dev/null +++ b/apps/api/src/services/messaging/outbound.ts @@ -0,0 +1,157 @@ +import { getDb, conversations, messages, clients, businessSettings, eq, and } from "@groombook/db"; +import { v4 as uuidv4 } from "uuid"; +import { sendSms } from "../sms.js"; + +export interface SendMessageOptions { + businessId: string; + clientId: string; + body: string; + sentByStaffId?: string; + mediaUrls?: string[]; +} + +export interface SendMessageResult { + messageId: string; + providerMessageId: string; + status: string; + suppressed: false; +} + +export interface SendMessageSuppressed { + suppressed: true; +} + +export type SendMessageResponse = SendMessageResult | SendMessageSuppressed; + +export class MissingTenantPhoneNumberError extends Error { + constructor() { + super("Tenant messagingPhoneNumber is not configured"); + this.name = "MissingTenantPhoneNumberError"; + } +} + +async function findOrCreateConversation( + businessId: string, + clientId: string, + externalNumber: string, + businessNumber: string +): Promise<{ id: string }> { + const db = getDb(); + + const [existing] = await db + .select({ id: conversations.id }) + .from(conversations) + .where( + and( + eq(conversations.businessId, businessId), + eq(conversations.externalNumber, externalNumber), + eq(conversations.businessNumber, businessNumber) + ) + ) + .limit(1); + + if (existing) return { id: existing.id }; + + const [created] = await db + .insert(conversations) + .values({ + id: uuidv4(), + businessId, + clientId, + channel: "sms", + externalNumber, + businessNumber, + lastMessageAt: new Date(), + status: "active", + }) + .returning({ id: conversations.id }); + + return { id: created.id }; +} + +async function resolveFromNumber(businessId: string): Promise { + const db = getDb(); + const [settings] = await db + .select({ messagingPhoneNumber: businessSettings.messagingPhoneNumber }) + .from(businessSettings) + .where(eq(businessSettings.id, businessId)) + .limit(1); + return settings?.messagingPhoneNumber ?? null; +} + +export async function sendMessage(opts: SendMessageOptions): Promise { + const db = getDb(); + const { businessId, clientId, body, sentByStaffId, mediaUrls } = opts; + + const [client] = await db + .select({ phone: clients.phone, smsOptIn: clients.smsOptIn }) + .from(clients) + .where(eq(clients.id, clientId)) + .limit(1); + + if (!client?.phone) { + return { suppressed: true }; + } + + if (!client.smsOptIn) { + return { suppressed: true }; + } + + const from = await resolveFromNumber(businessId); + if (!from) throw new MissingTenantPhoneNumberError(); + + const to = client.phone; + const conversationId = (await findOrCreateConversation(businessId, clientId, to, from)).id; + + const [queuedMessage] = await db + .insert(messages) + .values({ + id: uuidv4(), + conversationId, + direction: "outbound", + body, + status: "queued", + sentByStaffId: sentByStaffId ?? null, + }) + .returning({ id: messages.id }); + + try { + const result = await sendSms(to, body, mediaUrls); + + await db + .update(messages) + .set({ + status: "sent", + providerMessageId: result.messageId, + updatedAt: new Date(), + }) + .where(eq(messages.id, queuedMessage.id)); + + await db + .update(conversations) + .set({ lastMessageAt: new Date(), updatedAt: new Date() }) + .where(eq(conversations.id, conversationId)); + + return { + messageId: queuedMessage.id, + providerMessageId: result.messageId, + status: result.status, + suppressed: false, + }; + } catch (err) { + const errorCode = err instanceof Error ? err.name : "UNKNOWN"; + const errorMessage = err instanceof Error ? err.message : String(err); + + await db + .update(messages) + .set({ + status: "failed", + errorCode, + errorMessage, + updatedAt: new Date(), + }) + .where(eq(messages.id, queuedMessage.id)); + + throw err; + } +} \ No newline at end of file diff --git a/apps/api/src/services/sms.ts b/apps/api/src/services/sms.ts index 0f3f083..5a2a9c3 100644 --- a/apps/api/src/services/sms.ts +++ b/apps/api/src/services/sms.ts @@ -1,7 +1,5 @@ import { Telnyx } from "telnyx"; import { createHmac } from "crypto"; -import { v4 as uuidv4 } from "uuid"; -import { getDb, conversations, messages, businessSettings, eq, and } from "@groombook/db"; export interface SmsProvider { sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>; @@ -34,48 +32,6 @@ function isE164(phone: string): boolean { return /^\+[1-9]\d{7,14}$/.test(phone); } -async function findOrCreateConversationForOutbound( - businessId: string, - clientPhone: string, - businessNumber: string -): Promise<{ id: string; clientId: string }> { - const db = getDb(); - - const [existing] = await db - .select({ id: conversations.id, clientId: conversations.clientId }) - .from(conversations) - .where( - and( - eq(conversations.businessId, businessId), - eq(conversations.externalNumber, clientPhone), - eq(conversations.businessNumber, businessNumber) - ) - ) - .limit(1); - - if (existing) { - return { id: existing.id, clientId: existing.clientId }; - } - - const clientId = uuidv4(); - - const [created] = await db - .insert(conversations) - .values({ - id: uuidv4(), - businessId, - clientId, - channel: "sms", - externalNumber: clientPhone, - businessNumber, - lastMessageAt: new Date(), - status: "active", - }) - .returning({ id: conversations.id, clientId: conversations.clientId }); - - return { id: created.id, clientId: created.clientId }; -} - export async function sendSms( to: string, body: string, @@ -102,38 +58,6 @@ export async function sendSms( const result = await client.messages.create(payload as Record); const smsResult = result.data as unknown as TelnyxSmsResult; - - const db = getDb(); - const [settings] = await db - .select({ id: businessSettings.id }) - .from(businessSettings) - .where(eq(businessSettings.messagingPhoneNumber, from)) - .limit(1); - - if (settings?.id) { - const { id: conversationId } = await findOrCreateConversationForOutbound( - settings.id, - to, - from - ); - - await db - .update(conversations) - .set({ lastMessageAt: new Date(), updatedAt: new Date() }) - .where(eq(conversations.id, conversationId)); - - await db - .insert(messages) - .values({ - id: uuidv4(), - conversationId, - direction: "outbound", - body, - status: "sent", - providerMessageId: smsResult.message_id, - }); - } - return { messageId: smsResult.message_id, status: smsResult.status,