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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string | null> {
|
||||
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<SendMessageResponse> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user