Просмотр исходного кода

Add SMS tests (#13)

* Add SMS tests

* lint
Bernt Christian Egeland 7 месяцев назад
Родитель
Сommit
331ab95f23
2 измененных файлов с 688 добавлено и 2 удалено
  1. 668 0
      src/__tests__/features/sms/sms-send.test.ts
  2. 20 2
      src/lib/sms.ts

+ 668 - 0
src/__tests__/features/sms/sms-send.test.ts

@@ -0,0 +1,668 @@
+/**
+ * SMS Send Tests
+ *
+ * Covers three critical invariants:
+ * 1. SMS must never be sent to the wrong customer
+ * 2. Phone numbers must have a country code (E.164 format)
+ * 3. "SMS Sent" must never be shown unless the vendor confirmed acceptance
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// ── Mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock("@/lib/cached-session", () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}));
+
+vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
+
+vi.mock("@/lib/features", () => ({
+  requireFeature: vi.fn(),
+}));
+
+vi.mock("@/lib/db", () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    customer: { findFirst: vi.fn() },
+    smsMessage: {
+      create: vi.fn(),
+      update: vi.fn(),
+    },
+    appSetting: {
+      findUnique: vi.fn(),
+      findMany: vi.fn(),
+    },
+  },
+}));
+
+// Mock global fetch for vendor API calls
+const mockFetch = vi.fn();
+vi.stubGlobal("fetch", mockFetch);
+
+import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
+import { db } from "@/lib/db";
+import { sendSmsToCustomer } from "@/features/sms/Actions/smsActions";
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+const ORG_ID = "org-1";
+
+const CUSTOMER_A = {
+  id: "cust-a",
+  name: "Alice",
+  phone: "+15551234567",
+};
+
+const CUSTOMER_B = {
+  id: "cust-b",
+  name: "Bob",
+  phone: "+15559999999",
+};
+
+const SMS_RECORD = {
+  id: "sms-1",
+  direction: "outbound",
+  fromNumber: "+15550000000",
+  toNumber: CUSTOMER_A.phone,
+  body: "Hello",
+  status: "queued",
+  organizationId: ORG_ID,
+  customerId: CUSTOMER_A.id,
+};
+
+function setupAuth() {
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: "user-1", email: "user@example.com" },
+  } as any);
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG_ID,
+    role: "owner",
+    roleId: null,
+    customRole: null,
+  } as any);
+  vi.mocked(db.user.findUnique).mockResolvedValue({
+    isSuperAdmin: false,
+  } as any);
+}
+
+function setupSmsProvider(provider: "twilio" | "vonage" | "telnyx" = "twilio") {
+  vi.mocked(db.appSetting.findUnique).mockImplementation(({ where }: any) => {
+    const key = where.organizationId_key?.key;
+    if (key === "sms.provider") return { value: provider } as any;
+    if (key === "sms.phoneNumber") return { value: "+15550000000" } as any;
+    return null as any;
+  });
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([
+    { key: "sms.provider", value: provider },
+    { key: "sms.phoneNumber", value: "+15550000000" },
+    { key: "sms.twilio.accountSid", value: "AC_test" },
+    { key: "sms.twilio.authToken", value: "token_test" },
+    { key: "sms.vonage.apiKey", value: "vonage_key" },
+    { key: "sms.vonage.apiSecret", value: "vonage_secret" },
+    { key: "sms.telnyx.apiKey", value: "telnyx_key" },
+  ] as any);
+}
+
+function mockTwilioSuccess() {
+  mockFetch.mockResolvedValueOnce({
+    ok: true,
+    json: async () => ({ sid: "SM_abc123", status: "queued" }),
+  });
+}
+
+function mockTwilioFailure() {
+  mockFetch.mockResolvedValueOnce({
+    ok: false,
+    statusText: "Bad Request",
+    json: async () => ({ message: "Invalid 'To' Phone Number" }),
+  });
+}
+
+function mockVonageSuccess() {
+  mockFetch.mockResolvedValueOnce({
+    ok: true,
+    json: async () => ({
+      messages: [{ "message-id": "vonage_123", status: "0" }],
+    }),
+  });
+}
+
+function mockVonageFailure() {
+  mockFetch.mockResolvedValueOnce({
+    ok: true,
+    json: async () => ({
+      messages: [{ status: "3", "error-text": "Invalid phone number" }],
+    }),
+  });
+}
+
+function mockTelnyxSuccess() {
+  mockFetch.mockResolvedValueOnce({
+    ok: true,
+    json: async () => ({ data: { id: "telnyx_123" } }),
+  });
+}
+
+function mockTelnyxFailure() {
+  mockFetch.mockResolvedValueOnce({
+    ok: false,
+    statusText: "Unprocessable Entity",
+    json: async () => ({ errors: [{ detail: "Invalid destination number" }] }),
+  });
+}
+
+beforeEach(() => {
+  vi.resetAllMocks();
+});
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 1. SMS must never be sent to the wrong customer
+// ═════════════════════════════════════════════════════════════════════════════
+
+describe("SMS is never sent to the wrong customer", () => {
+  it("uses the phone number from the database, not from user input", async () => {
+    setupAuth();
+    setupSmsProvider("twilio");
+    vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+    vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+    vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+    mockTwilioSuccess();
+
+    await sendSmsToCustomer({
+      customerId: CUSTOMER_A.id,
+      body: "Hello",
+    });
+
+    // The 'To' parameter sent to Twilio must be the DB phone, not arbitrary input
+    const fetchCall = mockFetch.mock.calls[0];
+    const sentBody = fetchCall[1].body as string;
+    expect(sentBody).toContain(encodeURIComponent(CUSTOMER_A.phone));
+  });
+
+  it("scopes customer lookup to the caller's organization", async () => {
+    setupAuth();
+    setupSmsProvider("twilio");
+    vi.mocked(db.customer.findFirst).mockResolvedValue(null);
+
+    const result = await sendSmsToCustomer({
+      customerId: "cust-from-other-org",
+      body: "Hello",
+    });
+
+    expect(result.success).toBe(false);
+    expect(result.error).toContain("Customer not found");
+
+    // Verify the query was scoped to the org
+    expect(vi.mocked(db.customer.findFirst)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: expect.objectContaining({
+          id: "cust-from-other-org",
+          organizationId: ORG_ID,
+        }),
+      }),
+    );
+  });
+
+  it("refuses to send when customer has no phone number", async () => {
+    setupAuth();
+    setupSmsProvider("twilio");
+    vi.mocked(db.customer.findFirst).mockResolvedValue({
+      id: "cust-no-phone",
+      name: "No Phone",
+      phone: null,
+    } as any);
+
+    const result = await sendSmsToCustomer({
+      customerId: "cust-no-phone",
+      body: "Hello",
+    });
+
+    expect(result.success).toBe(false);
+    expect(result.error).toContain("no phone number");
+    expect(mockFetch).not.toHaveBeenCalled();
+  });
+
+  it("sends to customer A's number, not customer B's, when given customer A's id", async () => {
+    setupAuth();
+    setupSmsProvider("twilio");
+    vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+    vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+    vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+    mockTwilioSuccess();
+
+    await sendSmsToCustomer({
+      customerId: CUSTOMER_A.id,
+      body: "Hello Alice",
+    });
+
+    const fetchCall = mockFetch.mock.calls[0];
+    const sentBody = fetchCall[1].body as string;
+    // Must contain Alice's number
+    expect(sentBody).toContain(encodeURIComponent(CUSTOMER_A.phone));
+    // Must NOT contain Bob's number
+    expect(sentBody).not.toContain(encodeURIComponent(CUSTOMER_B.phone));
+  });
+
+  it("stores the correct customerId on the SMS record", async () => {
+    setupAuth();
+    setupSmsProvider("twilio");
+    vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+    vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+    vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+    mockTwilioSuccess();
+
+    await sendSmsToCustomer({
+      customerId: CUSTOMER_A.id,
+      body: "Hello",
+    });
+
+    expect(vi.mocked(db.smsMessage.create)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({
+          customerId: CUSTOMER_A.id,
+          toNumber: CUSTOMER_A.phone,
+        }),
+      }),
+    );
+  });
+});
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 2. Phone number must have country code (E.164 format)
+// ═════════════════════════════════════════════════════════════════════════════
+
+describe("Phone number E.164 validation", () => {
+  it("sends successfully when phone number is valid E.164 (+15551234567)", async () => {
+    setupAuth();
+    setupSmsProvider("twilio");
+    vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+    vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+    vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+    mockTwilioSuccess();
+
+    const result = await sendSmsToCustomer({
+      customerId: CUSTOMER_A.id,
+      body: "Hello",
+    });
+
+    expect(result.success).toBe(true);
+  });
+
+  it("rejects phone numbers without + prefix before calling the vendor", async () => {
+    setupAuth();
+    setupSmsProvider("twilio");
+    vi.mocked(db.customer.findFirst).mockResolvedValue({
+      id: "cust-bad-phone",
+      name: "Bad Phone",
+      phone: "5551234567",
+    } as any);
+    vi.mocked(db.smsMessage.create).mockResolvedValue({
+      ...SMS_RECORD,
+      id: "sms-bad",
+      toNumber: "5551234567",
+    } as any);
+    vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+
+    const result = await sendSmsToCustomer({
+      customerId: "cust-bad-phone",
+      body: "Hello",
+    });
+
+    expect(result.success).toBe(false);
+    expect(result.error).toContain("E.164");
+    expect(mockFetch).not.toHaveBeenCalled();
+  });
+
+  it("rejects phone numbers shorter than 8 digits", async () => {
+    setupAuth();
+    setupSmsProvider("twilio");
+    vi.mocked(db.customer.findFirst).mockResolvedValue({
+      id: "cust-short",
+      name: "Short",
+      phone: "+12345",
+    } as any);
+    vi.mocked(db.smsMessage.create).mockResolvedValue({
+      ...SMS_RECORD,
+      id: "sms-short",
+      toNumber: "+12345",
+    } as any);
+    vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+
+    const result = await sendSmsToCustomer({
+      customerId: "cust-short",
+      body: "Hello",
+    });
+
+    expect(result.success).toBe(false);
+    expect(result.error).toContain("E.164");
+    expect(mockFetch).not.toHaveBeenCalled();
+  });
+
+  it("rejects phone numbers with letters or special characters", async () => {
+    setupAuth();
+    setupSmsProvider("twilio");
+    vi.mocked(db.customer.findFirst).mockResolvedValue({
+      id: "cust-alpha",
+      name: "Alpha",
+      phone: "+1555ABC1234",
+    } as any);
+    vi.mocked(db.smsMessage.create).mockResolvedValue({
+      ...SMS_RECORD,
+      id: "sms-alpha",
+      toNumber: "+1555ABC1234",
+    } as any);
+    vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+
+    const result = await sendSmsToCustomer({
+      customerId: "cust-alpha",
+      body: "Hello",
+    });
+
+    expect(result.success).toBe(false);
+    expect(result.error).toContain("E.164");
+    expect(mockFetch).not.toHaveBeenCalled();
+  });
+
+  it("rejects numbers starting with +0", async () => {
+    setupAuth();
+    setupSmsProvider("twilio");
+    vi.mocked(db.customer.findFirst).mockResolvedValue({
+      id: "cust-zero",
+      name: "Zero",
+      phone: "+05551234567",
+    } as any);
+    vi.mocked(db.smsMessage.create).mockResolvedValue({
+      ...SMS_RECORD,
+      id: "sms-zero",
+      toNumber: "+05551234567",
+    } as any);
+    vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+
+    const result = await sendSmsToCustomer({
+      customerId: "cust-zero",
+      body: "Hello",
+    });
+
+    expect(result.success).toBe(false);
+    expect(result.error).toContain("E.164");
+    expect(mockFetch).not.toHaveBeenCalled();
+  });
+});
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 2b. Vonage receives digits-only (no '+' prefix)
+// ═════════════════════════════════════════════════════════════════════════════
+
+describe("Vonage number formatting", () => {
+  it("strips the + prefix from to and from numbers for Vonage", async () => {
+    setupAuth();
+    setupSmsProvider("vonage");
+    vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+    vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+    vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+    mockVonageSuccess();
+
+    await sendSmsToCustomer({
+      customerId: CUSTOMER_A.id,
+      body: "Hello",
+    });
+
+    const fetchCall = mockFetch.mock.calls[0];
+    const sentBody = JSON.parse(fetchCall[1].body as string);
+    // Vonage should receive digits only, no +
+    expect(sentBody.to).toBe("15551234567");
+    expect(sentBody.from).toBe("15550000000");
+    expect(sentBody.to).not.toContain("+");
+    expect(sentBody.from).not.toContain("+");
+  });
+});
+
+// ═════════════════════════════════════════════════════════════════════════════
+// 3. Never show "SMS Sent" unless vendor confirmed acceptance
+// ═════════════════════════════════════════════════════════════════════════════
+
+describe("SMS status reflects actual vendor response", () => {
+  describe("Twilio", () => {
+    it("returns success only after Twilio accepts the message", async () => {
+      setupAuth();
+      setupSmsProvider("twilio");
+      vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+      vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+      vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+      mockTwilioSuccess();
+
+      const result = await sendSmsToCustomer({
+        customerId: CUSTOMER_A.id,
+        body: "Hello",
+      });
+
+      expect(result.success).toBe(true);
+      expect(result.data).toEqual(
+        expect.objectContaining({ status: "sent" }),
+      );
+
+      // DB record updated to "sent" with providerMsgId
+      expect(vi.mocked(db.smsMessage.update)).toHaveBeenCalledWith(
+        expect.objectContaining({
+          data: expect.objectContaining({
+            status: "sent",
+            providerMsgId: "SM_abc123",
+          }),
+        }),
+      );
+    });
+
+    it("returns failure and marks record as failed when Twilio rejects", async () => {
+      setupAuth();
+      setupSmsProvider("twilio");
+      vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+      vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+      vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+      mockTwilioFailure();
+
+      const result = await sendSmsToCustomer({
+        customerId: CUSTOMER_A.id,
+        body: "Hello",
+      });
+
+      expect(result.success).toBe(false);
+      expect(result.error).toContain("Failed to send SMS");
+
+      // DB record updated to "failed" with error
+      expect(vi.mocked(db.smsMessage.update)).toHaveBeenCalledWith(
+        expect.objectContaining({
+          data: expect.objectContaining({
+            status: "failed",
+            errorMessage: expect.stringContaining("Twilio error"),
+          }),
+        }),
+      );
+    });
+  });
+
+  describe("Vonage", () => {
+    it("returns success only after Vonage confirms status 0", async () => {
+      setupAuth();
+      setupSmsProvider("vonage");
+      vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+      vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+      vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+      mockVonageSuccess();
+
+      const result = await sendSmsToCustomer({
+        customerId: CUSTOMER_A.id,
+        body: "Hello",
+      });
+
+      expect(result.success).toBe(true);
+      expect(vi.mocked(db.smsMessage.update)).toHaveBeenCalledWith(
+        expect.objectContaining({
+          data: expect.objectContaining({
+            status: "sent",
+            providerMsgId: "vonage_123",
+          }),
+        }),
+      );
+    });
+
+    it("returns failure when Vonage response status is non-zero", async () => {
+      setupAuth();
+      setupSmsProvider("vonage");
+      vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+      vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+      vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+      mockVonageFailure();
+
+      const result = await sendSmsToCustomer({
+        customerId: CUSTOMER_A.id,
+        body: "Hello",
+      });
+
+      expect(result.success).toBe(false);
+      expect(vi.mocked(db.smsMessage.update)).toHaveBeenCalledWith(
+        expect.objectContaining({
+          data: expect.objectContaining({
+            status: "failed",
+            errorMessage: expect.stringContaining("Vonage error"),
+          }),
+        }),
+      );
+    });
+  });
+
+  describe("Telnyx", () => {
+    it("returns success only after Telnyx accepts the message", async () => {
+      setupAuth();
+      setupSmsProvider("telnyx");
+      vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+      vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+      vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+      mockTelnyxSuccess();
+
+      const result = await sendSmsToCustomer({
+        customerId: CUSTOMER_A.id,
+        body: "Hello",
+      });
+
+      expect(result.success).toBe(true);
+      expect(vi.mocked(db.smsMessage.update)).toHaveBeenCalledWith(
+        expect.objectContaining({
+          data: expect.objectContaining({
+            status: "sent",
+            providerMsgId: "telnyx_123",
+          }),
+        }),
+      );
+    });
+
+    it("returns failure when Telnyx rejects the message", async () => {
+      setupAuth();
+      setupSmsProvider("telnyx");
+      vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+      vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+      vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+      mockTelnyxFailure();
+
+      const result = await sendSmsToCustomer({
+        customerId: CUSTOMER_A.id,
+        body: "Hello",
+      });
+
+      expect(result.success).toBe(false);
+      expect(vi.mocked(db.smsMessage.update)).toHaveBeenCalledWith(
+        expect.objectContaining({
+          data: expect.objectContaining({
+            status: "failed",
+            errorMessage: expect.stringContaining("Telnyx error"),
+          }),
+        }),
+      );
+    });
+  });
+
+  it("initial SMS record is created with status 'queued', not 'sent'", async () => {
+    setupAuth();
+    setupSmsProvider("twilio");
+    vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+    vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+    vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+    mockTwilioSuccess();
+
+    await sendSmsToCustomer({
+      customerId: CUSTOMER_A.id,
+      body: "Hello",
+    });
+
+    // The initial create must be "queued" — never "sent" before vendor confirms
+    expect(vi.mocked(db.smsMessage.create)).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({
+          status: "queued",
+        }),
+      }),
+    );
+  });
+
+  it("never updates to 'sent' if the vendor call throws", async () => {
+    setupAuth();
+    setupSmsProvider("twilio");
+    vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+    vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+    vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+
+    // Simulate a network error — vendor never responds
+    mockFetch.mockRejectedValueOnce(new Error("Network error"));
+
+    const result = await sendSmsToCustomer({
+      customerId: CUSTOMER_A.id,
+      body: "Hello",
+    });
+
+    expect(result.success).toBe(false);
+
+    // Must have updated to "failed", never to "sent"
+    const updateCalls = vi.mocked(db.smsMessage.update).mock.calls;
+    expect(updateCalls).toHaveLength(1);
+    expect(updateCalls[0][0]).toEqual(
+      expect.objectContaining({
+        data: expect.objectContaining({
+          status: "failed",
+        }),
+      }),
+    );
+  });
+
+  it("does not call vendor API if customer lookup fails", async () => {
+    setupAuth();
+    setupSmsProvider("twilio");
+    vi.mocked(db.customer.findFirst).mockResolvedValue(null);
+
+    await sendSmsToCustomer({
+      customerId: "nonexistent",
+      body: "Hello",
+    });
+
+    expect(mockFetch).not.toHaveBeenCalled();
+    expect(vi.mocked(db.smsMessage.create)).not.toHaveBeenCalled();
+  });
+
+  it("does not call vendor API if SMS provider is not configured", async () => {
+    setupAuth();
+    // No provider configured
+    vi.mocked(db.appSetting.findUnique).mockResolvedValue(null);
+    vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any);
+    vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any);
+    vi.mocked(db.smsMessage.update).mockResolvedValue({} as any);
+
+    const result = await sendSmsToCustomer({
+      customerId: CUSTOMER_A.id,
+      body: "Hello",
+    });
+
+    expect(result.success).toBe(false);
+    expect(result.error).toContain("not configured");
+    expect(mockFetch).not.toHaveBeenCalled();
+  });
+});

+ 20 - 2
src/lib/sms.ts

@@ -115,14 +115,18 @@ async function sendViaVonage(
     throw new Error("Vonage is not configured. Add your API Key and Secret.");
     throw new Error("Vonage is not configured. Add your API Key and Secret.");
   }
   }
 
 
+  // Vonage REST API expects digits only (no '+' prefix)
+  const vonageTo = to.replace(/^\+/, "");
+  const vonageFrom = from.replace(/^\+/, "");
+
   const res = await fetch("https://rest.nexmo.com/sms/json", {
   const res = await fetch("https://rest.nexmo.com/sms/json", {
     method: "POST",
     method: "POST",
     headers: { "Content-Type": "application/json" },
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify({
     body: JSON.stringify({
       api_key: apiKey,
       api_key: apiKey,
       api_secret: apiSecret,
       api_secret: apiSecret,
-      from,
-      to,
+      from: vonageFrom,
+      to: vonageTo,
       text: body,
       text: body,
     }),
     }),
   });
   });
@@ -180,6 +184,14 @@ async function sendViaTelnyx(
   return { id: data.data.id };
   return { id: data.data.id };
 }
 }
 
 
+// ─── Validation ─────────────────────────────────────────────────────────────
+
+const E164_REGEX = /^\+[1-9]\d{6,14}$/;
+
+export function isValidE164(phone: string): boolean {
+  return E164_REGEX.test(phone);
+}
+
 // ─── Main dispatch ───────────────────────────────────────────────────────────
 // ─── Main dispatch ───────────────────────────────────────────────────────────
 
 
 export interface SendSmsResult {
 export interface SendSmsResult {
@@ -190,6 +202,12 @@ export async function sendOrgSms(
   organizationId: string,
   organizationId: string,
   options: SendSmsOptions,
   options: SendSmsOptions,
 ): Promise<SendSmsResult> {
 ): Promise<SendSmsResult> {
+  if (!isValidE164(options.to)) {
+    throw new Error(
+      "Invalid phone number format. Must be E.164 format (e.g. +15551234567).",
+    );
+  }
+
   const provider = await getOrgSmsProvider(organizationId);
   const provider = await getOrgSmsProvider(organizationId);
   if (!provider) {
   if (!provider) {
     throw new Error("SMS is not configured. Set up an SMS provider in Settings.");
     throw new Error("SMS is not configured. Set up an SMS provider in Settings.");