From 7e83db479cbb8754fa379f925ddd0cfe57f3fab9 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 4 May 2026 17:45:56 +0000 Subject: [PATCH 1/5] feat(GRO-984): outbound SMS persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sendMessage() to outbound.ts with opt-in check, find/create conversation, queued→sent/failed transitions - Add unit tests for success, opt-out suppression, missing tenant phone - Add uuid and @types/uuid to package.json dependencies - sms.ts unchanged (already Telnyx transport only on dev) PR for GRO-1016 (replacement for #379) Closes GRO-984 Co-Authored-By: Paperclip --- apps/api/package.json | 2 + .../messaging/__tests__/outbound.test.ts | 200 ++++++++++++++++++ apps/api/src/services/messaging/outbound.ts | 159 ++++++++++++++ pnpm-lock.yaml | 19 ++ 4 files changed, 380 insertions(+) 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/package.json b/apps/api/package.json index e8d4488..a7c8876 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,6 +24,7 @@ "nodemailer": "^6.9.16", "stripe": "^22.0.0", "telnyx": "^1.23.0", + "uuid": "^11.0.5", "zod": "^4.3.6" }, @@ -31,6 +32,7 @@ "@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/services/messaging/__tests__/outbound.test.ts b/apps/api/src/services/messaging/__tests__/outbound.test.ts new file mode 100644 index 0000000..38558c9 --- /dev/null +++ b/apps/api/src/services/messaging/__tests__/outbound.test.ts @@ -0,0 +1,200 @@ +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 new file mode 100644 index 0000000..cd56a85 --- /dev/null +++ b/apps/api/src/services/messaging/outbound.ts @@ -0,0 +1,159 @@ +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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22f713a..f586e98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: telnyx: specifier: ^1.23.0 version: 1.27.0 + uuid: + specifier: ^11.0.5 + version: 11.1.1 zod: specifier: ^4.3.6 version: 4.3.6 @@ -59,6 +62,9 @@ importers: '@types/nodemailer': specifier: ^6.4.17 version: 6.4.23 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 '@vitest/coverage-v8': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) @@ -2334,6 +2340,9 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@typescript-eslint/eslint-plugin@8.57.1': resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4344,12 +4353,18 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true victory-vendor@37.3.6: @@ -6910,6 +6925,8 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@types/uuid@10.0.0': {} + '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -9014,6 +9031,8 @@ snapshots: dependencies: react: 19.2.4 + uuid@11.1.1: {} + uuid@8.3.2: {} uuid@9.0.1: {} -- 2.52.0 From 57fe69eaf7d66e97b6eba72860708a3a7973bac4 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 4 May 2026 21:20:26 +0000 Subject: [PATCH 2/5] fix(auth): override Better Auth sign-in rate limit defaults Override Better Auth default rate limits for /sign-in/* and /sign-up/* paths by adding customRules to both rateLimit blocks in auth.ts: - /sign-in/social: max 10, window 60 - /sign-in/email: max 10, window 60 - /sign-up/email: max 5, window 60 - /get-session: false (unchanged) Co-Authored-By: Paperclip --- apps/api/src/lib/auth.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 209e9d6..f90dee7 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -96,12 +96,15 @@ export async function initAuth(): Promise { max: 100, window: 10, storage: "memory", - customRules: { - "/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, }, - plugins: [ - genericOAuth({ + }, + plugins: [ + genericOAuth({ config: [ { providerId: "authentik", @@ -247,6 +250,9 @@ export async function initAuth(): Promise { 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, }, }, -- 2.52.0 From 28b3caccfeea22da160b2b6a93406d024e4dfa6a Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 4 May 2026 21:26:15 +0000 Subject: [PATCH 3/5] fix(auth): override Better Auth sign-in rate limit defaults QA fixes: - Fix indentation in first rateLimit block (10-space indent) - Remove out-of-scope files (outbound.ts, outbound.test.ts) - Remove uuid/@types/uuid dependencies added for out-of-scope files - Add cc @cpfarhood to PR description --- apps/api/package.json | 3 - apps/api/src/lib/auth.ts | 12 +- .../messaging/__tests__/outbound.test.ts | 200 ------------------ apps/api/src/services/messaging/outbound.ts | 159 -------------- 4 files changed, 6 insertions(+), 368 deletions(-) delete mode 100644 apps/api/src/services/messaging/__tests__/outbound.test.ts delete mode 100644 apps/api/src/services/messaging/outbound.ts 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 -- 2.52.0 From 5bb10743aba8a76ff62ae284324b95af03e5d487 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 4 May 2026 21:28:59 +0000 Subject: [PATCH 4/5] fix(auth): correct indentation in both rateLimit blocks - First block: customRules at 10-space indent (inside rateLimit at 8) - Second block: same 10-space indent structure - Co-Authored-By: Paperclip --- apps/api/src/lib/auth.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 54b1b38..8839098 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -96,15 +96,15 @@ export async function initAuth(): Promise { 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, + }, }, - }, - plugins: [ - genericOAuth({ + plugins: [ + genericOAuth({ config: [ { providerId: "authentik", @@ -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, -- 2.52.0 From 2883287785b148b843b7318d305d5866032c41cb Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 5 May 2026 03:32:30 +0000 Subject: [PATCH 5/5] fix(api): regenerate pnpm-lock.yaml without uuid deps - Remove stale uuid@11.1.1 and @types/uuid@10.0.0 entries - Resolves QA concern about stale lockfile after outbound.ts removal Co-Authored-By: Paperclip --- pnpm-lock.yaml | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f586e98..11641d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,9 +46,6 @@ importers: telnyx: specifier: ^1.23.0 version: 1.27.0 - uuid: - specifier: ^11.0.5 - version: 11.1.1 zod: specifier: ^4.3.6 version: 4.3.6 @@ -62,9 +59,6 @@ importers: '@types/nodemailer': specifier: ^6.4.17 version: 6.4.23 - '@types/uuid': - specifier: ^10.0.0 - version: 10.0.0 '@vitest/coverage-v8': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) @@ -2340,9 +2334,6 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} - '@types/uuid@10.0.0': - resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} - '@typescript-eslint/eslint-plugin@8.57.1': resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4353,10 +4344,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - uuid@11.1.1: - resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} - hasBin: true - uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -6925,8 +6912,6 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@types/uuid@10.0.0': {} - '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -9031,8 +9016,6 @@ snapshots: dependencies: react: 19.2.4 - uuid@11.1.1: {} - uuid@8.3.2: {} uuid@9.0.1: {} -- 2.52.0