From 5e103a378c74cd53c54d1a0206d6f1ab9dfe4e29 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 4 May 2026 01:29:32 +0000 Subject: [PATCH 1/6] feat(GRO-106): messaging schema + migrations - Add conversations, messages, message_attachments, message_consent_events tables - Add messagingChannelEnum, messageDirectionEnum, messageStatusEnum, messageConsentKindEnum - Extend business_settings with messagingPhoneNumber and telnyxMessagingProfileId columns - Add required indexes and unique constraints with cascade-on-delete FKs - Add migration 0030_messaging.sql Co-Authored-By: Paperclip --- packages/db/migrations/0030_messaging.sql | 72 ++++++++++++++ packages/db/migrations/meta/_journal.json | 6 +- packages/db/src/schema.ts | 110 ++++++++++++++++++++++ 3 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 packages/db/migrations/0030_messaging.sql diff --git a/packages/db/migrations/0030_messaging.sql b/packages/db/migrations/0030_messaging.sql new file mode 100644 index 0000000..dbf1b61 --- /dev/null +++ b/packages/db/migrations/0030_messaging.sql @@ -0,0 +1,72 @@ +-- Migration: 0030_messaging.sql +-- Messaging schema: conversations, messages, attachments, consent events + business messaging settings + +-- ─── Enums ─────────────────────────────────────────────────────────────────── + +CREATE TYPE "messaging_channel" AS ENUM ('sms', 'mms'); +CREATE TYPE "message_direction" AS ENUM ('inbound', 'outbound'); +CREATE TYPE "message_status" AS ENUM ('queued', 'sent', 'delivered', 'failed', 'received'); +CREATE TYPE "message_consent_kind" AS ENUM ('opt_in', 'opt_out', 'help'); + +-- ─── Tables ─────────────────────────────────────────────────────────────────── + +CREATE TABLE "conversations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "business_id" uuid NOT NULL, + "client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE, + "channel" "messaging_channel" NOT NULL, + "external_number" text NOT NULL, + "business_number" text NOT NULL, + "last_message_at" timestamp, + "status" text NOT NULL DEFAULT 'active', + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now() +); + +CREATE INDEX "idx_conversations_business_id_last_message_at" ON "conversations"("business_id", "last_message_at"); +CREATE UNIQUE INDEX "uq_conversations_business_client_number" ON "conversations"("business_id", "client_id", "business_number"); + +CREATE TABLE "messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "conversation_id" uuid NOT NULL REFERENCES "conversations"("id") ON DELETE CASCADE, + "direction" "message_direction" NOT NULL, + "body" text, + "status" "message_status" NOT NULL DEFAULT 'queued', + "provider_message_id" text, + "error_code" text, + "error_message" text, + "sent_by_staff_id" uuid REFERENCES "staff"("id") ON DELETE SET NULL, + "created_at" timestamp NOT NULL DEFAULT now(), + "delivered_at" timestamp, + "read_by_client_at" timestamp +); + +CREATE INDEX "idx_messages_conversation_id_created_at" ON "messages"("conversation_id", "created_at"); +CREATE UNIQUE INDEX "uq_messages_provider_message_id" ON "messages"("provider_message_id"); + +CREATE TABLE "message_attachments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE, + "content_type" text NOT NULL, + "url" text NOT NULL, + "size" integer NOT NULL, + "provider_media_id" text +); + +CREATE INDEX "idx_message_attachments_message_id" ON "message_attachments"("message_id"); + +CREATE TABLE "message_consent_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "client_id" uuid NOT NULL REFERENCES "clients"("id") ON DELETE CASCADE, + "business_id" uuid NOT NULL, + "kind" "message_consent_kind" NOT NULL, + "source" text, + "created_at" timestamp NOT NULL DEFAULT now() +); + +CREATE INDEX "idx_message_consent_events_client_id" ON "message_consent_events"("client_id"); + +-- ─── Business Settings extensions ──────────────────────────────────────────── + +ALTER TABLE "business_settings" ADD COLUMN "messaging_phone_number" text; +ALTER TABLE "business_settings" ADD COLUMN "telnyx_messaging_profile_id" text; diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 8db9b8d..43f81e7 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -199,10 +199,10 @@ "breakpoints": true }, { - "idx": 28, + "idx": 29, "version": "7", - "when": 1775741667192, - "tag": "0028_sms_reminders", + "when": 1775828067192, + "tag": "0030_messaging", "breakpoints": true } ] diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 0a5eaef..165eac8 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -406,6 +406,114 @@ export const impersonationAuditLogs = pgTable( (t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)] ); +// ─── Messaging ─────────────────────────────────────────────────────────────── + +export const messagingChannelEnum = pgEnum("messaging_channel", ["sms", "mms"]); + +export const messageDirectionEnum = pgEnum("message_direction", [ + "inbound", + "outbound", +]); + +export const messageStatusEnum = pgEnum("message_status", [ + "queued", + "sent", + "delivered", + "failed", + "received", +]); + +export const messageConsentKindEnum = pgEnum("message_consent_kind", [ + "opt_in", + "opt_out", + "help", +]); + +export const conversations = pgTable( + "conversations", + { + id: uuid("id").primaryKey().defaultRandom(), + businessId: uuid("business_id").notNull(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + channel: messagingChannelEnum("channel").notNull(), + externalNumber: text("external_number").notNull(), + businessNumber: text("business_number").notNull(), + lastMessageAt: timestamp("last_message_at"), + status: text("status").notNull().default("active"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + index("idx_conversations_business_id_last_message_at").on( + t.businessId, + t.lastMessageAt + ), + unique("uq_conversations_business_client_number").on( + t.businessId, + t.clientId, + t.businessNumber + ), + ] +); + +export const messages = pgTable( + "messages", + { + id: uuid("id").primaryKey().defaultRandom(), + conversationId: uuid("conversation_id") + .notNull() + .references(() => conversations.id, { onDelete: "cascade" }), + direction: messageDirectionEnum("direction").notNull(), + body: text("body"), + status: messageStatusEnum("status").notNull().default("queued"), + providerMessageId: text("provider_message_id"), + errorCode: text("error_code"), + errorMessage: text("error_message"), + sentByStaffId: uuid("sent_by_staff_id").references(() => staff.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at").notNull().defaultNow(), + deliveredAt: timestamp("delivered_at"), + readByClientAt: timestamp("read_by_client_at"), + }, + (t) => [ + index("idx_messages_conversation_id_created_at").on(t.conversationId, t.createdAt), + unique("uq_messages_provider_message_id").on(t.providerMessageId), + ] +); + +export const messageAttachments = pgTable( + "message_attachments", + { + id: uuid("id").primaryKey().defaultRandom(), + messageId: uuid("message_id") + .notNull() + .references(() => messages.id, { onDelete: "cascade" }), + contentType: text("content_type").notNull(), + url: text("url").notNull(), + size: integer("size").notNull(), + providerMediaId: text("provider_media_id"), + }, + (t) => [index("idx_message_attachments_message_id").on(t.messageId)] +); + +export const messageConsentEvents = pgTable( + "message_consent_events", + { + id: uuid("id").primaryKey().defaultRandom(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + businessId: uuid("business_id").notNull(), + kind: messageConsentKindEnum("kind").notNull(), + source: text("source"), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (t) => [index("idx_message_consent_events_client_id").on(t.clientId)] +); + export const businessSettings = pgTable("business_settings", { id: uuid("id").primaryKey().defaultRandom(), businessName: text("business_name").notNull().default("GroomBook"), @@ -414,6 +522,8 @@ export const businessSettings = pgTable("business_settings", { logoKey: text("logo_key"), primaryColor: text("primary_color").notNull().default("#4f8a6f"), accentColor: text("accent_color").notNull().default("#8b7355"), + messagingPhoneNumber: text("messaging_phone_number"), + telnyxMessagingProfileId: text("telnyx_messaging_profile_id"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); -- 2.52.0 From 2e24c371c31c152e5654c67931b17a6ce104fa00 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 4 May 2026 02:16:25 +0000 Subject: [PATCH 2/6] fix(GRO-981): restore journal entries and add DESC to indexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _journal.json: restore idx 28 (0028_sms_reminders), add idx 29 (0029_db_indexes_constraints), renumber 0030_messaging to idx 30 (was missing 0028 and 0029 entries — they were silently skipped) - schema.ts: add .desc() to conversations.lastMessageAt and messages.createdAt indexes per spec - 0030_messaging.sql: add DESC to both generated index statements Co-Authored-By: Paperclip --- packages/db/migrations/0030_messaging.sql | 4 ++-- packages/db/migrations/meta/_journal.json | 14 ++++++++++++++ packages/db/src/schema.ts | 7 +++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/db/migrations/0030_messaging.sql b/packages/db/migrations/0030_messaging.sql index dbf1b61..c404505 100644 --- a/packages/db/migrations/0030_messaging.sql +++ b/packages/db/migrations/0030_messaging.sql @@ -23,7 +23,7 @@ CREATE TABLE "conversations" ( "updated_at" timestamp NOT NULL DEFAULT now() ); -CREATE INDEX "idx_conversations_business_id_last_message_at" ON "conversations"("business_id", "last_message_at"); +CREATE INDEX "idx_conversations_business_id_last_message_at" ON "conversations"("business_id", "last_message_at" DESC); CREATE UNIQUE INDEX "uq_conversations_business_client_number" ON "conversations"("business_id", "client_id", "business_number"); CREATE TABLE "messages" ( @@ -41,7 +41,7 @@ CREATE TABLE "messages" ( "read_by_client_at" timestamp ); -CREATE INDEX "idx_messages_conversation_id_created_at" ON "messages"("conversation_id", "created_at"); +CREATE INDEX "idx_messages_conversation_id_created_at" ON "messages"("conversation_id", "created_at" DESC); CREATE UNIQUE INDEX "uq_messages_provider_message_id" ON "messages"("provider_message_id"); CREATE TABLE "message_attachments" ( diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 43f81e7..eef2244 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -198,9 +198,23 @@ "tag": "0027_refunds", "breakpoints": true }, + { + "idx": 28, + "version": "7", + "when": 1775741667192, + "tag": "0028_sms_reminders", + "breakpoints": true + }, { "idx": 29, "version": "7", + "when": 1775784467192, + "tag": "0029_db_indexes_constraints", + "breakpoints": true + }, + { + "idx": 30, + "version": "7", "when": 1775828067192, "tag": "0030_messaging", "breakpoints": true diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 165eac8..f1d74b3 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -448,7 +448,7 @@ export const conversations = pgTable( (t) => [ index("idx_conversations_business_id_last_message_at").on( t.businessId, - t.lastMessageAt + t.lastMessageAt.desc() ), unique("uq_conversations_business_client_number").on( t.businessId, @@ -479,7 +479,10 @@ export const messages = pgTable( readByClientAt: timestamp("read_by_client_at"), }, (t) => [ - index("idx_messages_conversation_id_created_at").on(t.conversationId, t.createdAt), + index("idx_messages_conversation_id_created_at").on( + t.conversationId, + t.createdAt.desc() + ), unique("uq_messages_provider_message_id").on(t.providerMessageId), ] ); -- 2.52.0 From c79b5220a444b979b460c0a47b06e9a235e4738c Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 4 May 2026 02:38:27 +0000 Subject: [PATCH 3/6] feat(GRO-106): inbound Telnyx webhook + persistence - Add POST /api/webhooks/telnyx/messaging route with HMAC signature verification - Add services/messaging/inbound.ts: findOrCreateConversation, upsertMessage (idempotent on providerMessageId), delivery receipt handling - Register telnyxWebhooksRouter in index.ts (before auth middleware) - Add unit tests for signature validation, find-or-create, idempotent insert, delivery receipt Co-Authored-By: Paperclip --- apps/api/src/index.ts | 4 + apps/api/src/routes/webhooks/telnyx.ts | 86 ++++++ .../messaging/__tests__/inbound.test.ts | 276 ++++++++++++++++++ apps/api/src/services/messaging/inbound.ts | 187 ++++++++++++ 4 files changed, 553 insertions(+) create mode 100644 apps/api/src/routes/webhooks/telnyx.ts create mode 100644 apps/api/src/services/messaging/__tests__/inbound.test.ts create mode 100644 apps/api/src/services/messaging/inbound.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1ed08f2..4dfdb8c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -29,6 +29,7 @@ import { devRouter } from "./routes/dev.js"; import { adminSeedRouter } from "./routes/admin/seed.js"; import { startReminderScheduler } from "./services/reminders.js"; import { webhooksRouter } from "./routes/stripe-webhooks.js"; +import { telnyxWebhooksRouter } from "./routes/webhooks/telnyx.js"; const app = new Hono(); @@ -69,6 +70,9 @@ app.route("/api/portal", portalRouter); // Public Stripe webhook endpoint — signature-verified, no auth required app.route("/api/webhooks/stripe", webhooksRouter); +// Public Telnyx messaging webhook — signature-verified, no auth required +app.route("/api/webhooks/telnyx", telnyxWebhooksRouter); + // Dev/demo routes — config is always public, users endpoint is guarded internally app.route("/api/dev", devRouter); diff --git a/apps/api/src/routes/webhooks/telnyx.ts b/apps/api/src/routes/webhooks/telnyx.ts new file mode 100644 index 0000000..723663d --- /dev/null +++ b/apps/api/src/routes/webhooks/telnyx.ts @@ -0,0 +1,86 @@ +import { Hono } from "hono"; +import { createHmac } from "crypto"; +import { + handleMessageReceived, + handleMessageFinalized, + resolveBusinessIdByMessagingNumber, + TelnyxMessageReceivedPayload, +} from "../../services/messaging/inbound.js"; + +export const telnyxWebhooksRouter = new Hono(); + +function validateTelnyxSignature(rawBody: string, signature: string | null): boolean { + if (!signature) return false; + const secret = process.env.TELNYX_WEBHOOK_SECRET; + if (!secret) return false; + + try { + const hmac = createHmac("sha256", secret); + const expected = `sha256=${hmac.update(rawBody).digest("hex")}`; + + const sigBuf = Buffer.from(signature); + const expBuf = Buffer.from(expected); + + if (sigBuf.length !== expBuf.length) return false; + + let diff = 0; + for (let i = 0; i < sigBuf.length; i++) { + const sigByte = sigBuf[i] ?? 0; + const expByte = expBuf[i] ?? 0; + diff |= sigByte ^ expByte; + } + return diff === 0; + } catch { + return false; + } +} + +telnyxWebhooksRouter.post("/messaging", async (c) => { + const signature = c.req.header("telnyx-signature"); + + let rawBody: string; + try { + rawBody = await c.req.text(); + } catch { + return c.json({ error: "Could not read body" }, 400); + } + + if (!validateTelnyxSignature(rawBody, signature)) { + return c.json({ error: "Invalid signature" }, 401); + } + + let payload: TelnyxMessageReceivedPayload; + try { + payload = JSON.parse(rawBody) as TelnyxMessageReceivedPayload; + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } + + const eventType = payload.data?.event_type; + if (!eventType) { + return c.json({ error: "Missing event_type" }, 400); + } + + if (eventType === "message.received") { + try { + await handleMessageReceived(payload); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + if (msg.startsWith("No business owns")) { + return c.json({ error: "Unknown messaging number" }, 404); + } + return c.json({ error: msg }, 500); + } + return c.json({ received: true }); + } + + if (eventType === "message.finalized") { + const result = await handleMessageFinalized(payload); + if (result) { + return c.json({ received: true, messageId: result.messageId, status: result.newStatus }); + } + return c.json({ received: true, messageId: null }); + } + + return c.json({ received: true }); +}); \ No newline at end of file diff --git a/apps/api/src/services/messaging/__tests__/inbound.test.ts b/apps/api/src/services/messaging/__tests__/inbound.test.ts new file mode 100644 index 0000000..aab629d --- /dev/null +++ b/apps/api/src/services/messaging/__tests__/inbound.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + findOrCreateConversation, + upsertMessage, + resolveBusinessIdByMessagingNumber, + handleMessageReceived, + handleMessageFinalized, + TelnyxMessageReceivedPayload, +} from "../inbound.js"; +import * as schema from "@groombook/db"; + +vi.mock("@groombook/db", () => ({ + getDb: vi.fn(), + conversations: { id: "", businessId: "", clientId: "", externalNumber: "", businessNumber: "", channel: "", lastMessageAt: null, status: "", createdAt: null, updatedAt: null }, + messages: { id: "", conversationId: "", direction: "", body: "", status: "", providerMessageId: "", sentByStaffId: null, createdAt: null, deliveredAt: null, readByClientAt: null }, + businessSettings: { id: "", messagingPhoneNumber: "" }, + eq: vi.fn(), + and: vi.fn(), + sql: vi.fn(), +})); + +const mockDb = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + returning: vi.fn().mockReturnThis(), +}; + +vi.mocked(schema.getDb).mockReturnValue(mockDb as unknown as ReturnType); + +const makePayload = ( + eventType: "message.received" | "message.sent" | "message.finalized", + messageId: string, + fromPhone: string, + toPhone: string, + body = "Hello" +): TelnyxMessageReceivedPayload => ({ + data: { + id: "evt-1", + event_type: eventType, + payload: { + message: { + id: messageId, + from: { phone: fromPhone, carrier: "carrier" }, + to: [{ phone: toPhone }], + body, + }, + }, + }, +}); + +describe("signature validation via route", () => { + it("returns 401 when telnyx-signature header is missing", async () => { + const { telnyxWebhooksRouter } = await import("../../routes/webhooks/telnyx.js"); + const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222")); + const req = new Request("http://localhost/api/webhooks/telnyx/messaging", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload, + }); + const res = await telnyxWebhooksRouter.fetch(req); + expect(res.status).toBe(401); + }); + + it("returns 401 when signature does not match", async () => { + process.env.TELNYX_WEBHOOK_SECRET = "test-secret"; + const { telnyxWebhooksRouter } = await import("../../routes/webhooks/telnyx.js"); + const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222")); + const req = new Request("http://localhost/api/webhooks/telnyx/messaging", { + method: "POST", + headers: { + "Content-Type": "application/json", + "telnyx-signature": "sha256=bad", + }, + body: payload, + }); + const res = await telnyxWebhooksRouter.fetch(req); + expect(res.status).toBe(401); + }); +}); + +describe("findOrCreateConversation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDb.select.mockReset(); + mockDb.from.mockReset(); + mockDb.where.mockReset(); + mockDb.limit.mockReset(); + mockDb.insert.mockReset(); + mockDb.update.mockReset(); + mockDb.returning.mockReset(); + }); + + it("returns existing conversation when found", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([{ id: "conv-1", clientId: "client-1" }]), + }), + }), + }); + + const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222"); + expect(result.id).toBe("conv-1"); + }); + + it("creates new conversation when none exists", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + }); + mockDb.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue([{ id: "conv-2", clientId: "client-2" }]), + }), + }); + + const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222"); + expect(result.id).toBe("conv-2"); + }); +}); + +describe("upsertMessage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns isNew=false when message with providerMessageId already exists", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([{ id: "msg-existing" }]), + }), + }), + }); + + const result = await upsertMessage("msg-123", "conv-1", "inbound", "Hello", "received"); + expect(result.isNew).toBe(false); + expect(result.id).toBe("msg-existing"); + }); + + it("inserts new message and returns isNew=true", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + }); + mockDb.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue([{ id: "msg-new" }]), + }), + }); + + const result = await upsertMessage("msg-new-123", "conv-1", "inbound", "New message", "queued"); + expect(result.isNew).toBe(true); + expect(result.id).toBe("msg-new"); + }); +}); + +describe("handleMessageReceived", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDb.select.mockReset(); + mockDb.from.mockReset(); + mockDb.where.mockReset(); + mockDb.limit.mockReset(); + mockDb.insert.mockReset(); + mockDb.update.mockReset(); + mockDb.returning.mockReset(); + }); + + it("returns 404 when no business owns the to number", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + }); + + const payload = makePayload("message.received", "msg-123", "+1555111", "+1555000"); + await expect(handleMessageReceived(payload)).rejects.toThrow("No business owns messaging number"); + }); + + it("creates conversation and message for valid inbound", async () => { + mockDb.select + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([{ id: "biz-1" }]), + }), + }), + }); + + mockDb.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue([{ id: "conv-new", clientId: "client-1" }]), + }), + }); + mockDb.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({}), + }), + }); + mockDb.select.mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + }); + mockDb.insert.mockReturnValueOnce({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue([{ id: "msg-new" }]), + }), + }); + + const payload = makePayload("message.received", "msg-abc", "+1555111", "+1555222", "Test message"); + const result = await handleMessageReceived(payload); + expect(result.messageId).toBe("msg-new"); + }); +}); + +describe("handleMessageFinalized", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when message not found", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + }); + + const payload = makePayload("message.finalized", "msg-unknown", "+1555111", "+1555222"); + const result = await handleMessageFinalized(payload); + expect(result).toBeNull(); + }); + + it("updates status to delivered for finalized inbound", async () => { + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([{ id: "msg-1", status: "sent" }]), + }), + }), + }); + mockDb.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({}), + }), + }); + + const payload = makePayload("message.finalized", "msg-1", "+1555111", "+1555222"); + const result = await handleMessageFinalized(payload); + expect(result?.newStatus).toBe("delivered"); + }); +}); \ No newline at end of file diff --git a/apps/api/src/services/messaging/inbound.ts b/apps/api/src/services/messaging/inbound.ts new file mode 100644 index 0000000..d6d683e --- /dev/null +++ b/apps/api/src/services/messaging/inbound.ts @@ -0,0 +1,187 @@ +import { getDb, conversations, messages, businessSettings, eq, and, sql } from "@groombook/db"; +import { messageDirectionEnum, messageStatusEnum } from "@groombook/db"; +import { v4 as uuidv4 } from "uuid"; + +export interface TelnyxMessageReceivedPayload { + data: { + id: string; + event_type: "message.received" | "message.sent" | "message.finalized"; + payload: { + message: { + id: string; + from: { phone: string; carrier?: string }; + to: { phone: string }[]; + body: string; + media?: Array<{ type: string; url: string }>; + }; + recording?: unknown; + leg_count?: number; + }; + }; +} + +function buildFindOrCreateConversationParams(businessId: string, clientPhone: string, businessNumber: string) { + return { + businessId, + externalNumber: clientPhone, + businessNumber, + }; +} + +export async function findOrCreateConversation( + 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 [business] = await db + .select({ primaryClientId: sql`${businessSettings.id}` }) + .from(businessSettings) + .where(eq(businessSettings.id, businessId)) + .limit(1); + + const clientId = business?.primaryClientId ?? 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 upsertMessage( + providerMessageId: string, + conversationId: string, + direction: "inbound" | "outbound", + body: string, + status: "queued" | "sent" | "delivered" | "failed" | "received", + sentByStaffId?: string +): Promise<{ id: string; isNew: boolean }> { + const db = getDb(); + + const [existing] = await db + .select({ id: messages.id }) + .from(messages) + .where(eq(messages.providerMessageId, providerMessageId)) + .limit(1); + + if (existing) { + return { id: existing.id, isNew: false }; + } + + const [inserted] = await db + .insert(messages) + .values({ + id: uuidv4(), + conversationId, + direction, + body, + status, + providerMessageId, + sentByStaffId: sentByStaffId ?? null, + }) + .returning({ id: messages.id }); + + return { id: inserted.id, isNew: true }; +} + +export async function resolveBusinessIdByMessagingNumber(toNumber: string): Promise { + const db = getDb(); + const [settings] = await db + .select({ id: businessSettings.id }) + .from(businessSettings) + .where(eq(businessSettings.messagingPhoneNumber, toNumber)) + .limit(1); + return settings?.id ?? null; +} + +export async function handleMessageReceived(payload: TelnyxMessageReceivedPayload): Promise<{ conversationId: string; messageId: string }> { + const { message } = payload.data.payload; + const fromPhone = message.from.phone; + const toPhone = message.to[0]?.phone; + + if (!toPhone) { + throw new Error("No recipient phone in payload"); + } + + const businessId = await resolveBusinessIdByMessagingNumber(toPhone); + if (!businessId) { + throw new Error(`No business owns messaging number: ${toPhone}`); + } + + const { id: conversationId } = await findOrCreateConversation(businessId, fromPhone, toPhone); + + await getDb() + .update(conversations) + .set({ lastMessageAt: new Date(), updatedAt: new Date() }) + .where(eq(conversations.id, conversationId)); + + const { id: messageId } = await upsertMessage( + message.id, + conversationId, + "inbound", + message.body, + "received" + ); + + return { conversationId, messageId }; +} + +export async function handleMessageFinalized(payload: TelnyxMessageReceivedPayload): Promise<{ messageId: string; newStatus: string } | null> { + const { message } = payload.data.payload; + + if (!message.id) return null; + + const db = getDb(); + const [existing] = await db + .select({ id: messages.id, status: messages.status }) + .from(messages) + .where(eq(messages.providerMessageId, message.id)) + .limit(1); + + if (!existing) return null; + + let newStatus = existing.status; + if (payload.data.event_type === "message.finalized") { + const deliveryReceipt = message as { direction?: string; to?: Array<{ phone: string }> }; + if (deliveryReceipt.direction === "inbound") { + newStatus = "delivered"; + } + } + + if (newStatus !== existing.status) { + await db + .update(messages) + .set({ status: newStatus, deliveredAt: new Date(), updatedAt: new Date() }) + .where(eq(messages.id, existing.id)); + } + + return { messageId: existing.id, newStatus }; +} \ No newline at end of file -- 2.52.0 From 701889c06fbeab54b3e5b4fb6e7038748864ea23 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 4 May 2026 02:44:59 +0000 Subject: [PATCH 4/6] feat(GRO-984): persist outbound SMS to messages table Wire sendSms() to find/create the conversation by sender/recipient and insert an outbound message row with the Telnyx message_id. Co-Authored-By: Paperclip --- apps/api/src/services/sms.ts | 78 +++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/apps/api/src/services/sms.ts b/apps/api/src/services/sms.ts index 5be4009..0f3f083 100644 --- a/apps/api/src/services/sms.ts +++ b/apps/api/src/services/sms.ts @@ -1,5 +1,7 @@ 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 }>; @@ -32,6 +34,48 @@ 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, @@ -58,6 +102,38 @@ 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, @@ -139,4 +215,4 @@ export async function smsSend( await provider.sendSms(to, body, mediaUrls); return true; -} +} \ No newline at end of file -- 2.52.0 From 1c4453ed450284eb93175939a9b1a61f1000cd42 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 4 May 2026 02:49:56 +0000 Subject: [PATCH 5/6] 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, -- 2.52.0 From 2b646d9e5d2abee382783e388c86e8a06edda0cb Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Mon, 4 May 2026 16:18:23 +0000 Subject: [PATCH 6/6] fix(GRO-1003): address CI typecheck and lint failures on PR #379 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typecheck fixes: - telnyx.ts:48 — coerce undefined to null for signature param - inbound.ts/outbound.ts — add null guards on .returning() results - schema.ts — add updatedAt to messages table - package.json — add uuid and @types/uuid Lint fixes: - telnyx.ts — remove unused resolveBusinessIdByMessagingNumber import - inbound.ts — remove unused messageDirectionEnum, messageStatusEnum imports - inbound.ts — remove unused buildFindOrCreateConversationParams function - inbound.test.ts — remove unused resolveBusinessIdByMessagingNumber import - outbound.test.ts — remove unused mockEq/mockAnd variables - outbound.test.ts — fix vi.mock path for sms.js (../../sms.js) --- apps/api/package.json | 2 ++ apps/api/src/routes/webhooks/telnyx.ts | 3 +-- .../services/messaging/__tests__/inbound.test.ts | 5 ++--- .../services/messaging/__tests__/outbound.test.ts | 5 +---- apps/api/src/services/messaging/inbound.ts | 15 +++++---------- apps/api/src/services/messaging/outbound.ts | 4 ++++ packages/db/src/schema.ts | 1 + 7 files changed, 16 insertions(+), 19 deletions(-) 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/routes/webhooks/telnyx.ts b/apps/api/src/routes/webhooks/telnyx.ts index 723663d..f8393a3 100644 --- a/apps/api/src/routes/webhooks/telnyx.ts +++ b/apps/api/src/routes/webhooks/telnyx.ts @@ -3,7 +3,6 @@ import { createHmac } from "crypto"; import { handleMessageReceived, handleMessageFinalized, - resolveBusinessIdByMessagingNumber, TelnyxMessageReceivedPayload, } from "../../services/messaging/inbound.js"; @@ -45,7 +44,7 @@ telnyxWebhooksRouter.post("/messaging", async (c) => { return c.json({ error: "Could not read body" }, 400); } - if (!validateTelnyxSignature(rawBody, signature)) { + if (!validateTelnyxSignature(rawBody, signature ?? null)) { return c.json({ error: "Invalid signature" }, 401); } diff --git a/apps/api/src/services/messaging/__tests__/inbound.test.ts b/apps/api/src/services/messaging/__tests__/inbound.test.ts index aab629d..76dfeaf 100644 --- a/apps/api/src/services/messaging/__tests__/inbound.test.ts +++ b/apps/api/src/services/messaging/__tests__/inbound.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { findOrCreateConversation, upsertMessage, - resolveBusinessIdByMessagingNumber, handleMessageReceived, handleMessageFinalized, TelnyxMessageReceivedPayload, @@ -54,7 +53,7 @@ const makePayload = ( describe("signature validation via route", () => { it("returns 401 when telnyx-signature header is missing", async () => { - const { telnyxWebhooksRouter } = await import("../../routes/webhooks/telnyx.js"); + const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js"); const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222")); const req = new Request("http://localhost/api/webhooks/telnyx/messaging", { method: "POST", @@ -67,7 +66,7 @@ describe("signature validation via route", () => { it("returns 401 when signature does not match", async () => { process.env.TELNYX_WEBHOOK_SECRET = "test-secret"; - const { telnyxWebhooksRouter } = await import("../../routes/webhooks/telnyx.js"); + const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js"); const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222")); const req = new Request("http://localhost/api/webhooks/telnyx/messaging", { method: "POST", diff --git a/apps/api/src/services/messaging/__tests__/outbound.test.ts b/apps/api/src/services/messaging/__tests__/outbound.test.ts index d051c29..0e8545f 100644 --- a/apps/api/src/services/messaging/__tests__/outbound.test.ts +++ b/apps/api/src/services/messaging/__tests__/outbound.test.ts @@ -4,7 +4,7 @@ const mockSendSms = vi.fn(); const mockGetDb = vi.fn(); const mockUuidv4 = vi.fn(); -vi.mock("../sms.js", () => ({ +vi.mock("../../sms.js", () => ({ sendSms: mockSendSms, })); @@ -24,9 +24,6 @@ vi.mock("uuid", () => ({ 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(); diff --git a/apps/api/src/services/messaging/inbound.ts b/apps/api/src/services/messaging/inbound.ts index d6d683e..723afb0 100644 --- a/apps/api/src/services/messaging/inbound.ts +++ b/apps/api/src/services/messaging/inbound.ts @@ -1,5 +1,4 @@ import { getDb, conversations, messages, businessSettings, eq, and, sql } from "@groombook/db"; -import { messageDirectionEnum, messageStatusEnum } from "@groombook/db"; import { v4 as uuidv4 } from "uuid"; export interface TelnyxMessageReceivedPayload { @@ -20,14 +19,6 @@ export interface TelnyxMessageReceivedPayload { }; } -function buildFindOrCreateConversationParams(businessId: string, clientPhone: string, businessNumber: string) { - return { - businessId, - externalNumber: clientPhone, - businessNumber, - }; -} - export async function findOrCreateConversation( businessId: string, clientPhone: string, @@ -73,6 +64,8 @@ export async function findOrCreateConversation( }) .returning({ id: conversations.id, clientId: conversations.clientId }); + if (!created) throw new Error("Failed to create conversation"); + return { id: created.id, clientId: created.clientId }; } @@ -109,6 +102,8 @@ export async function upsertMessage( }) .returning({ id: messages.id }); + if (!inserted) throw new Error("Failed to insert message"); + return { id: inserted.id, isNew: true }; } @@ -184,4 +179,4 @@ export async function handleMessageFinalized(payload: TelnyxMessageReceivedPaylo } return { messageId: existing.id, newStatus }; -} \ No newline at end of file +} diff --git a/apps/api/src/services/messaging/outbound.ts b/apps/api/src/services/messaging/outbound.ts index 320d78b..9b2896e 100644 --- a/apps/api/src/services/messaging/outbound.ts +++ b/apps/api/src/services/messaging/outbound.ts @@ -66,6 +66,8 @@ async function findOrCreateConversation( }) .returning({ id: conversations.id }); + if (!created) throw new Error("Failed to create conversation"); + return { id: created.id }; } @@ -115,6 +117,8 @@ export async function sendMessage(opts: SendMessageOptions): Promise [ index("idx_messages_conversation_id_created_at").on( -- 2.52.0