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 { Telnyx } from "telnyx";
|
||||||
import { createHmac } from "crypto";
|
import { createHmac } from "crypto";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import { getDb, conversations, messages, businessSettings, eq, and } from "@groombook/db";
|
|
||||||
|
|
||||||
export interface SmsProvider {
|
export interface SmsProvider {
|
||||||
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
|
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);
|
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(
|
export async function sendSms(
|
||||||
to: string,
|
to: string,
|
||||||
body: string,
|
body: string,
|
||||||
@@ -102,38 +58,6 @@ export async function sendSms(
|
|||||||
|
|
||||||
const result = await client.messages.create(payload as Record<string, string | string[]>);
|
const result = await client.messages.create(payload as Record<string, string | string[]>);
|
||||||
const smsResult = result.data as unknown as TelnyxSmsResult;
|
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 {
|
return {
|
||||||
messageId: smsResult.message_id,
|
messageId: smsResult.message_id,
|
||||||
status: smsResult.status,
|
status: smsResult.status,
|
||||||
|
|||||||
Reference in New Issue
Block a user