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

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-04 03:06:57 +00:00
committed by Flea Flicker [agent]
parent c79b5220a4
commit daba925fc8
4 changed files with 71 additions and 87 deletions
+2 -29
View File
@@ -1,40 +1,13 @@
import { Hono } from "hono";
import { createHmac } from "crypto";
import { validateTelnyxSignature } from "../../services/sms.js";
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");
@@ -83,4 +56,4 @@ telnyxWebhooksRouter.post("/messaging", async (c) => {
}
return c.json({ received: true });
});
});
@@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import {
findOrCreateConversation,
upsertMessage,
resolveBusinessIdByMessagingNumber,
handleMessageReceived,
handleMessageFinalized,
TelnyxMessageReceivedPayload,
@@ -53,8 +52,12 @@ const makePayload = (
});
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 { 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 +70,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",
@@ -273,4 +276,4 @@ describe("handleMessageFinalized", () => {
const result = await handleMessageFinalized(payload);
expect(result?.newStatus).toBe("delivered");
});
});
});
+32 -27
View File
@@ -1,6 +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 {
data: {
@@ -20,14 +18,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,
@@ -57,12 +47,12 @@ export async function findOrCreateConversation(
.where(eq(businessSettings.id, businessId))
.limit(1);
const clientId = business?.primaryClientId ?? uuidv4();
const clientId = business?.primaryClientId ?? crypto.randomUUID();
const [created] = await db
.insert(conversations)
.values({
id: uuidv4(),
id: crypto.randomUUID(),
businessId,
clientId,
channel: "sms",
@@ -73,6 +63,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 };
}
@@ -96,20 +88,33 @@ export async function upsertMessage(
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 });
try {
const [inserted] = await db
.insert(messages)
.values({
id: crypto.randomUUID(),
conversationId,
direction,
body,
status,
providerMessageId,
sentByStaffId: sentByStaffId ?? null,
})
.returning({ id: messages.id });
return { id: inserted.id, isNew: true };
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<string | null> {
@@ -179,9 +184,9 @@ export async function handleMessageFinalized(payload: TelnyxMessageReceivedPaylo
if (newStatus !== existing.status) {
await db
.update(messages)
.set({ status: newStatus, deliveredAt: new Date(), updatedAt: new Date() })
.set({ status: newStatus, deliveredAt: new Date() })
.where(eq(messages.id, existing.id));
}
return { messageId: existing.id, newStatus };
}
}
+30 -27
View File
@@ -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"));
}
}