From f0f271e046c19ac3ef91669e2ffb17e896e52e9d Mon Sep 17 00:00:00 2001 From: "the-dogfather-cto[bot]" <269737991+the-dogfather-cto[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 00:51:17 +0000 Subject: [PATCH 1/6] feat(GRO-106): inbound Telnyx webhook + persistence (#378) (#388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * fix(GRO-981): restore journal entries and add DESC to indexes - _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 * 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 * fix(GRO-982): address all QA blocking failures - #7: Extract validateTelnyxSignature in sms.ts as standalone exported fn, reuse in TelnyxProvider.validateWebhookSignature and telnyx.ts route - #1: Replace uuid v4 import with crypto.randomUUID() (built-in, no dep) - #2: Remove updatedAt from messages update in handleMessageFinalized (no such column exists) - #3: Fix test import path ../../ → ../../../ for telnyx route import - #4: validateTelnyxSignature accepts string | undefined | null to match Hono c.req.header() return type - #5&6: Add null guards for .returning() results in findOrCreateConversation and upsertMessage - #8: Remove dead buildFindOrCreateConversationParams function - #9: Remove unused imports (messageDirectionEnum, messageStatusEnum, resolveBusinessIdByMessagingNumber in test) - #10: Wrap upsertMessage insert in try/catch; unique violation returns {isNew: false} instead of crashing - #11: Add EOF newlines to all modified files * chore: add uuid dependency for messaging services * fix(GRO-982): address 5 test failures in inbound webhook - Fix signature route tests: use /messaging not full mount path - Fix handleMessageReceived mock order: business lookup first - Fix stale mock state: add full mockReset in handleMessageFinalized beforeEach - Fix delivery logic: set delivered for all message.finalized events - Deduplicate test that was accidentally added twice * fix(GRO-982): look up or create client by phone before inserting conversation Fixes FK constraint violation where clientId was set to businessSettings.id or a random UUID. Now looks up clients.phone = clientPhone first; if no match, creates a placeholder client with phone as name and a placeholder email. * fix(GRO-982): address QA round 4 blocking failures - Fix URL in signature tests: use /messaging not full path - Reorder mocks: businessSettings first, then conversations, clients, messages - Add mockDb.mockReset in handleMessageFinalized beforeEach - Remove direction guard: set delivered for any message.finalized * fix(GRO-982): add missing message insert mock in handleMessageReceived test * fix(GRO-982): simplify test mocks to match actual code flow --------- Co-authored-by: groombook-engineer[bot] <269742240+groombook-engineer[bot]@users.noreply.github.com> Co-authored-by: Chris Farhood Co-authored-by: Paperclip --- apps/api/package.json | 3 +- apps/api/src/index.ts | 4 + apps/api/src/routes/webhooks/telnyx.ts | 59 ++++ .../messaging/__tests__/inbound.test.ts | 313 ++++++++++++++++++ apps/api/src/services/messaging/inbound.ts | 200 +++++++++++ apps/api/src/services/sms.ts | 57 ++-- pnpm-lock.yaml | 2 +- 7 files changed, 608 insertions(+), 30 deletions(-) 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/package.json b/apps/api/package.json index a7c8876..8c9b6be 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,8 +24,7 @@ "nodemailer": "^6.9.16", "stripe": "^22.0.0", "telnyx": "^1.23.0", - "uuid": "^11.0.5", - + "uuid": "^11.1.1", "zod": "^4.3.6" }, "devDependencies": { 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..369461d --- /dev/null +++ b/apps/api/src/routes/webhooks/telnyx.ts @@ -0,0 +1,59 @@ +import { Hono } from "hono"; +import { validateTelnyxSignature } from "../../services/sms.js"; +import { + handleMessageReceived, + handleMessageFinalized, + TelnyxMessageReceivedPayload, +} from "../../services/messaging/inbound.js"; + +export const telnyxWebhooksRouter = new Hono(); + +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 }); +}); 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..b905824 --- /dev/null +++ b/apps/api/src/services/messaging/__tests__/inbound.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + findOrCreateConversation, + upsertMessage, + 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: "" }, + clients: { id: "", name: "", email: "", phone: "", status: "" }, + 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", () => { + beforeEach(() => { + vi.resetModules(); + }); + + 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/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/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"); + }); + + it("creates placeholder client for unknown phone then creates conversation", 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([]), + }), + }), + }); + mockDb.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue([{ id: "conv-3", clientId: "client-3" }]), + }), + }); + + const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222"); + expect(result.id).toBe("conv-3"); + expect(result.clientId).toBe("client-3"); + }); +}); + +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(); + mockDb.select.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue([]), + }), + }), + })); + }); + + it("returns 404 when no business owns the to number", async () => { + 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([{ id: "biz-1" }]), + }), + }), + }) + .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: "client-new" }]), + }), + }) + .mockReturnValueOnce({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockReturnValue([{ id: "conv-new", clientId: "client-new" }]), + }), + }); + mockDb.update.mockReturnValueOnce({ + set: vi.fn().mockReturnValue({ + where: 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(); + mockDb.select.mockReset(); + mockDb.from.mockReset(); + mockDb.where.mockReset(); + mockDb.limit.mockReset(); + mockDb.insert.mockReset(); + mockDb.update.mockReset(); + mockDb.returning.mockReset(); + }); + + 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({ + returning: vi.fn().mockReturnValue([{ id: "msg-1" }]), + }), + }), + }); + + const payload = makePayload("message.finalized", "msg-1", "+1555111", "+1555222"); + const result = await handleMessageFinalized(payload); + expect(result?.newStatus).toBe("delivered"); + }); +}); diff --git a/apps/api/src/services/messaging/inbound.ts b/apps/api/src/services/messaging/inbound.ts new file mode 100644 index 0000000..de9d0e4 --- /dev/null +++ b/apps/api/src/services/messaging/inbound.ts @@ -0,0 +1,200 @@ +import { getDb, conversations, messages, businessSettings, clients, eq, and } 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; + }; + }; +} + +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 [existingClient] = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.phone, clientPhone)) + .limit(1); + + const clientId = existingClient?.id ?? uuidv4(); + + if (!existingClient) { + await db.insert(clients).values({ + id: clientId, + name: clientPhone, + email: `sms-${uuidv4()}@placeholder.local`, + phone: clientPhone, + status: "active", + }); + } + + const [created] = await db + .insert(conversations) + .values({ + id: crypto.randomUUID(), + businessId, + clientId, + channel: "sms", + externalNumber: clientPhone, + businessNumber, + lastMessageAt: new Date(), + status: "active", + }) + .returning({ id: conversations.id, clientId: conversations.clientId }); + + if (!created) throw new Error("Failed to create conversation"); + + 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 }; + } + + try { + const [inserted] = await db + .insert(messages) + .values({ + id: crypto.randomUUID(), + conversationId, + direction, + body, + status, + providerMessageId, + sentByStaffId: sentByStaffId ?? null, + }) + .returning({ id: messages.id }); + + if (!inserted) throw new Error("Failed to insert message"); + return { id: inserted.id, isNew: true }; + } catch (err) { + if (err instanceof Error && err.message.includes("unique")) { + 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 }; + } + throw err; + } +} + +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") { + newStatus = "delivered"; + } + + if (newStatus !== existing.status) { + await db + .update(messages) + .set({ status: newStatus, deliveredAt: new Date() }) + .where(eq(messages.id, existing.id)); + } + + return { messageId: existing.id, newStatus }; +} diff --git a/apps/api/src/services/sms.ts b/apps/api/src/services/sms.ts index 5be4009..209e5e2 100644 --- a/apps/api/src/services/sms.ts +++ b/apps/api/src/services/sms.ts @@ -32,6 +32,35 @@ function isE164(phone: string): boolean { return /^\+[1-9]\d{7,14}$/.test(phone); } +export function validateTelnyxSignature( + rawBody: string, + signature: string | undefined | 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; + } +} + export async function sendSms( to: string, body: string, @@ -74,33 +103,7 @@ export class TelnyxProvider implements SmsProvider { } validateWebhookSignature(req: Request): boolean { - const secret = process.env.TELNYX_WEBHOOK_SECRET; - if (!secret) return false; - - const signature = req.headers.get("telnyx-signature"); - if (!signature) return false; - - const payload = JSON.stringify(req.body); - - try { - const hmac = createHmac("sha256", secret); - const expected = `sha256=${hmac.update(payload).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; - } + return validateTelnyxSignature(JSON.stringify(req.body), req.headers.get("telnyx-signature")); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f586e98..3f69c84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,7 +47,7 @@ importers: specifier: ^1.23.0 version: 1.27.0 uuid: - specifier: ^11.0.5 + specifier: ^11.1.1 version: 11.1.1 zod: specifier: ^4.3.6 -- 2.52.0 From 8eec29ad9040ee2402f6f4c724eb88ec3bae1189 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 19:27:00 +0000 Subject: [PATCH 2/6] fix: correct infra paths in promote-to-uat workflow Fix hardcoded apps/groombook/... paths to apps/... per GRO-1274. Co-Authored-By: Paperclip --- .github/workflows/promote-to-uat.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/promote-to-uat.yml b/.github/workflows/promote-to-uat.yml index 083e013..22f5680 100644 --- a/.github/workflows/promote-to-uat.yml +++ b/.github/workflows/promote-to-uat.yml @@ -38,7 +38,7 @@ jobs: run: | echo "Updating UAT overlay image tags to: $TAG" cd /tmp/infra - UAT_KUST="apps/groombook/overlays/uat/kustomization.yaml" + UAT_KUST="apps/overlays/uat/kustomization.yaml" if [ ! -f "$UAT_KUST" ]; then echo "ERROR: UAT overlay not found at $UAT_KUST. Ensure GRO-427 has been completed." @@ -55,7 +55,7 @@ jobs: yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST" # Update migrate Job name to include short SHA (immutable template fix) - MIGRATE_JOB="apps/groombook/base/migrate-job.yaml" + MIGRATE_JOB="apps/base/migrate-job.yaml" if [ -f "$MIGRATE_JOB" ]; then yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB" @@ -64,7 +64,7 @@ jobs: # Update seed Job name to include short SHA (immutable template fix) # NOTE: Do NOT update the image tag here — let the Kustomize images transformer # in the UAT overlay handle it via newTag. This avoids the immutable template issue. - SEED_JOB="apps/groombook/base/seed-job.yaml" + SEED_JOB="apps/base/seed-job.yaml" if [ -f "$SEED_JOB" ]; then yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" @@ -81,7 +81,7 @@ jobs: git config user.name "groombook-engineer[bot]" git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com" git checkout -b "chore/update-uat-image-tags-${TAG}" - git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml + git add apps/overlays/uat/ apps/base/migrate-job.yaml apps/base/seed-job.yaml git commit -m "chore: promote ${TAG} to UAT" git push -u origin "chore/update-uat-image-tags-${TAG}" -- 2.52.0 From 7339d51acf295e04b6b0e12ccee23ec99c028b42 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 19:40:59 +0000 Subject: [PATCH 3/6] fix: use window.location.origin as fallback for VITE_API_URL Vite bakes VITE_* vars at build time, so hardcoding a URL in .env.production breaks CI E2E which runs on localhost. Now falls back to the browser origin at runtime, which works correctly since nginx reverse-proxies /api to the local API container. Fixes GRO-1280. --- apps/web/src/lib/auth-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index 6a9939a..7c5db68 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -1,7 +1,7 @@ import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ - baseURL: import.meta.env.VITE_API_URL ?? "", + baseURL: import.meta.env.VITE_API_URL || window.location.origin, }); export const { signIn, signOut, useSession, changePassword } = authClient; \ No newline at end of file -- 2.52.0 From c2dd1dbf84e0624fc98812e759e7b974594308e8 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 19:51:34 +0000 Subject: [PATCH 4/6] fix: add explicit ARG/ENV VITE_API_URL to Dockerfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, Vite sees VITE_API_URL as undefined (not empty string) at build time. The ?? operator only replaces null/undefined, not a missing var, so better-auth receives undefined — which it treats as a relative path and prepends window.location.origin at build time, resulting in the UAT URL being baked in. Explicitly setting ARG VITE_API_URL= (empty string) in the Dockerfile makes Vite see it as defined with empty value, so the || fallback fires at runtime. Fixes GRO-1280. --- apps/web/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index eb110fa..f551990 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -11,6 +11,8 @@ RUN pnpm install --frozen-lockfile # Build FROM deps AS builder +ARG VITE_API_URL= +ENV VITE_API_URL= COPY packages/types/ packages/types/ COPY apps/web/ apps/web/ RUN pnpm --filter @groombook/web build -- 2.52.0 From 2398dabe3a21188569565835434f17f7f7c8c831 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 19:51:47 +0000 Subject: [PATCH 5/6] fix: set VITE_API_URL env var in Build job Ensures Vite sees VITE_API_URL as an empty string (not undefined) during pnpm build, so the || window.location.origin fallback fires at runtime instead of baking in the UAT URL. Co-Authored-By: Paperclip --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a8c173..1438d3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,8 @@ jobs: run: pnpm install --frozen-lockfile - name: Build all packages + env: + VITE_API_URL: "" run: pnpm build docker: -- 2.52.0 From 573869e5173e83120735a3c2565bcfd8c50c7bc5 Mon Sep 17 00:00:00 2001 From: "the-dogfather-cto[bot]" <269737991+the-dogfather-cto[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 20:16:22 +0000 Subject: [PATCH 6/6] fix: correct infra paths in promote-to-uat workflow (#414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Promote dev → uat: ARIA modal fix + tip split atomicity (#335) * feat(GRO-785): validate tip split totals before marking invoice paid - PATCH /invoices/:id returns 400 when tipCents > 0 but no tip splits exist or splits don't sum to 100% - POST /invoices/:id/tip-splits now returns 400 (not 422) on validation failure via router-level ZodError handler Co-Authored-By: Paperclip * feat(GRO-786): add ARIA label attributes to Modal dialog component - Update Modal component to accept title and titleStyle props - Add role="dialog", aria-modal="true", and aria-labelledby attributes - Use useId() to generate stable ID for title heading association - Update all 4 Modal call sites (New/Edit Client, Add/Edit Pet, Log Grooming Visit, Permanently Delete Client) with title props - Delete modal passes titleStyle for red color on warning Co-Authored-By: Paperclip * fix(GRO-786): remove duplicate dialog role and restore focus trap - Remove role="dialog" and aria-modal="true" from outer backdrop div - Keep ARIA attributes only on inner dialog div (the actual modal) - Restore useEffect focus management: auto-focus first element, Tab cycle wrapping, Escape key handler, focus restore on close Co-Authored-By: Paperclip * fix(GRO-785): restore atomic tip split save in PATCH and fix error message - When body.tipSplits is provided in PATCH /invoices/:id, validate sum first then atomically replace existing splits (delete + insert) - When no incoming splits, validate existing DB splits with corrected message: "Tip splits are required when tip amount is greater than zero" (previously misleading "must sum to 100%" when no splits existed) Co-Authored-By: Paperclip * fix(GRO-785): address invoice tip split regression - Use body.tipCents ?? current.tipCents for validation condition so that simultaneous status=paid + tipCents=0 skip split validation - Use body.tipCents (now aliased as tipCents) instead of current.tipCents inside the atomic transaction for shareCents calculation - Add explicit check for empty tipSplits array with appropriate error message ("Tip splits are required when tip amount is greater than zero") before the sum-to-100% check - Destructure tipSplits out of body before spreading into update object to prevent it from leaking into the invoices table SET clause Co-Authored-By: Paperclip * fix(GRO-785): wrap tip split save + invoice update in single transaction Both tip split persistence (delete + insert) and the invoice PATCH update are now inside one db.transaction() block. If the invoice update fails after splits are written, the entire operation rolls back. Also removed unnecessary eslint-disable comment on _tipSplits. Co-Authored-By: Paperclip * fix(GRO-785): restore eslint-disable for intentionally unused _tipSplits var Co-Authored-By: Paperclip --------- Co-authored-by: Flea Flicker Co-authored-by: Paperclip Co-authored-by: the-dogfather-cto[bot] <269737991+the-dogfather-cto[bot]@users.noreply.github.com> * fix(gro-609): include stripePaymentIntentId in invoice list and wrap stats endpoint in try/catch - Add stripePaymentIntentId to the GET /invoices list query so the refund button renders when seed data includes a payment intent ID - Wrap /api/invoices/stats/summary in try/catch so errors return 200 with zero defaults instead of 5xx, preventing the Invoices page from crashing on mount for groomer-role sessions Parent: GRO-882 Grandparent: GRO-816 Co-Authored-By: Paperclip * fix(gro-609): add payment stats to admin dashboard (AppointmentsPage) - Fetch /api/invoices/stats/summary on mount and display Revenue/Outstanding/Refunds summary cards above the calendar view on /admin - Mirrors the same stats section already on /admin/invoices - Gracefully handles errors via try/catch on the stats endpoint Parent: GRO-882 Grandparent: GRO-816 Co-Authored-By: Paperclip * fix(GRO-890): populate stripePaymentIntentId on all paid seed invoices All paid invoices created by the seed script now get a deterministic stripePaymentIntentId of the form pi_test_seed_NNNNNN, unblocking the refund button conditional in Invoices.tsx:514 during UAT. Pending/draft invoices retain null as before. Co-Authored-By: Paperclip * fix(GRO-898): update CI to deploy on dev branch pushes Update the Update Infra Image Tags job condition to also trigger on pushes to the dev branch, not just main. Co-Authored-By: Paperclip * fix: correct infra paths in promote-to-uat workflow Remove 'groombook/' prefix from 4 path references in promote-to-uat.yml since the groombook/infra repo has apps/overlays/ and apps/base/ at the root, not under a groombook/ subdirectory. GRO-1274 --------- Co-authored-by: the-dogfather-cto[bot] <269737991+the-dogfather-cto[bot]@users.noreply.github.com> Co-authored-by: Flea Flicker Co-authored-by: Paperclip Co-authored-by: lint-roller-qa[bot] <269744346+lint-roller-qa[bot]@users.noreply.github.com> Co-authored-by: scrubs-mcbarkley-ceo[bot] <269735724+scrubs-mcbarkley-ceo[bot]@users.noreply.github.com> Co-authored-by: Test User Co-authored-by: groombook-engineer[bot] <3141748+groombook-engineer[bot]@users.noreply.github.com> Co-authored-by: Chris Farhood -- 2.52.0