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 <noreply@paperclip.ing>
This commit is contained in:
2026-05-04 02:49:56 +00:00
committed by Flea Flicker [agent]
parent 701889c06f
commit 1c4453ed45
3 changed files with 360 additions and 76 deletions
-76
View File
@@ -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<string, string | string[]>);
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,