diff --git a/apps/api/package.json b/apps/api/package.json index a7c8876..70d24a9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,15 +24,12 @@ "nodemailer": "^6.9.16", "stripe": "^22.0.0", "telnyx": "^1.23.0", - "uuid": "^11.0.5", - "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^22.10.7", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.17", - "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.18.0", "tsx": "^4.19.2", diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index f90dee7..54b1b38 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -249,12 +249,12 @@ customRules: { max: 100, window: 10, storage: "memory", - customRules: { - "/sign-in/social": { max: 10, window: 60 }, - "/sign-in/email": { max: 10, window: 60 }, - "/sign-up/email": { max: 5, window: 60 }, - "/get-session": false, - }, + customRules: { + "/sign-in/social": { max: 10, window: 60 }, + "/sign-in/email": { max: 10, window: 60 }, + "/sign-up/email": { max: 5, window: 60 }, + "/get-session": false, + }, }, account: { storeStateStrategy: "cookie" as const, diff --git a/apps/api/src/services/messaging/__tests__/outbound.test.ts b/apps/api/src/services/messaging/__tests__/outbound.test.ts deleted file mode 100644 index 38558c9..0000000 --- a/apps/api/src/services/messaging/__tests__/outbound.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -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.js"); - -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 deleted file mode 100644 index cd56a85..0000000 --- a/apps/api/src/services/messaging/outbound.ts +++ /dev/null @@ -1,159 +0,0 @@ -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 }); - - if (!created) throw new Error("Failed to create conversation"); - - 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 }); - - if (!queuedMessage) throw new Error("Failed to insert queued message"); - - try { - const result = await sendSms(to, body, mediaUrls); - - await db - .update(messages) - .set({ - status: "sent", - providerMessageId: result.messageId, - }) - .where(eq(messages.id, queuedMessage.id)); - - await db - .update(conversations) - .set({ lastMessageAt: 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, - }) - .where(eq(messages.id, queuedMessage.id)); - - throw err; - } -} \ No newline at end of file