From 8d164bc6b6cca3efaf514ec87c2d4610cc7cbf6b Mon Sep 17 00:00:00 2001 From: Deyner lopez Date: Thu, 16 Apr 2026 11:00:44 -0500 Subject: [PATCH] https://mobileaws.atlassian.net/browse/CLOUD-2712 --- .gitignore | 8 + README.md | 266 +++++++++++++---- jest.config.js | 6 +- src/__tests__/ccai.test.ts | 154 ++++++++++ src/__tests__/contact.test.ts | 82 ++++++ src/__tests__/email.test.ts | 489 ++++++++++++++++++++++++++++++++ src/__tests__/mms.test.ts | 286 +++++++++++++++++++ src/__tests__/sms.test.ts | 179 ++++++++++++ src/__tests__/webhook.test.ts | 294 +++++++++++++++++++ src/ccai.ts | 254 ++++++++++++----- src/contact/contact.ts | 65 +++++ src/email/email.ts | 201 +++++++------ src/email_send.ts | 18 +- src/examples/async-example.ts | 65 ++--- src/examples/basic-example.ts | 34 +-- src/examples/email-examples.ts | 34 +-- src/examples/example.ts | 34 +-- src/examples/express-webhook.ts | 97 +++++-- src/examples/mms-example.ts | 68 ++--- src/examples/webhook-example.ts | 148 ++++++---- src/index.ts | 32 +-- src/mms_send.ts | 22 +- src/sms/mms.ts | 301 +++++++++++--------- src/sms/sms.ts | 67 +++-- src/sms_send.ts | 12 +- src/webhook/nextjs.ts | 46 ++- src/webhook/types.ts | 40 ++- src/webhook/webhook.ts | 167 +++++++++-- tsconfig.json | 6 +- 29 files changed, 2793 insertions(+), 682 deletions(-) create mode 100644 src/__tests__/ccai.test.ts create mode 100644 src/__tests__/contact.test.ts create mode 100644 src/__tests__/email.test.ts create mode 100644 src/__tests__/mms.test.ts create mode 100644 src/__tests__/sms.test.ts create mode 100644 src/__tests__/webhook.test.ts create mode 100644 src/contact/contact.ts diff --git a/.gitignore b/.gitignore index 1e04e7b..4e836c1 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,11 @@ coverage/ # Test files image.jpg + +# Real API tests — local only +# Real API tests — local only +src/test_real.ts +dist/test_real.js +dist/test_real.d.ts +test_real.ts +test-progress.ts diff --git a/README.md b/README.md index 4f97188..e2bde1b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CloudContactAI Node.js Client - [CloudContactAI](https://www.cloudcontactai.com) -A TypeScript client for the Cloud Contact AI API that allows you to easily send SMS and MMS messages, and handle webhook callbacks. +A TypeScript client for the Cloud Contact AI API that allows you to easily send SMS and MMS messages, send email campaigns, manage webhooks, and manage contact opt-out preferences. ## Requirements @@ -116,9 +116,139 @@ async function sendMmsWithImage() { sendMmsWithImage(); ``` +### Email + +```typescript +import { CCAI } from 'ccai-node'; + +const ccai = new CCAI({ + clientId: 'YOUR-CLIENT-ID', + apiKey: 'API-KEY-TOKEN' +}); + +// Send a single email +const response = await ccai.email.sendSingle( + 'John', + 'Doe', + 'john@example.com', + 'Welcome to Our Service', + '

Hello ${firstName},

Thank you for signing up!

', + 'noreply@yourcompany.com', + 'support@yourcompany.com', + 'Your Company', + 'Welcome Email' +); +console.log('Email sent:', response); + +// Send an email campaign to multiple recipients +const accounts = [ + { firstName: 'John', lastName: 'Doe', email: 'john@example.com' }, + { firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com' } +]; + +const campaignResponse = await ccai.email.send( + accounts, + 'Monthly Newsletter', + '

Hello ${firstName}!

Monthly updates...

', + 'newsletter@yourcompany.com', + 'support@yourcompany.com', + 'Your Company Newsletter', + 'July 2025 Newsletter' +); +console.log('Campaign sent:', campaignResponse); +``` + +### Contact + +Manage opt-out preferences for contacts. + +```typescript +import { CCAI } from 'ccai-node'; + +const ccai = new CCAI({ + clientId: 'YOUR-CLIENT-ID', + apiKey: 'API-KEY-TOKEN' +}); + +// Opt a contact out of text messages (by phone number) +const result = await ccai.contact.setDoNotText(true, undefined, '+15551234567'); +console.log('Opted out:', result); + +// Opt a contact back in (by phone number) +await ccai.contact.setDoNotText(false, undefined, '+15551234567'); + +// Opt out by contactId +await ccai.contact.setDoNotText(true, 'contact-abc-123'); +``` + ### Webhooks -CloudContactAI can send webhook notifications when certain events occur, such as when messages are sent or received. The library provides utilities to handle these webhook events in Next.js applications. +CloudContactAI can send webhook notifications when certain events occur, such as when messages are sent or received. Use the Webhook service to register, manage, and verify webhooks programmatically. + +#### Managing Webhooks + +```typescript +import { CCAI, WebhookConfig, WebhookEventType } from 'ccai-node'; + +const ccai = new CCAI({ + clientId: 'YOUR-CLIENT-ID', + apiKey: 'API-KEY-TOKEN' +}); + +// Example 1: Register a new webhook - server generates secret automatically +const webhookConfig: WebhookConfig = { + url: 'https://your-app.com/api/ccai-webhook', + // secret is optional - if not provided, server generates one automatically +}; +const webhook = await ccai.webhook.register(webhookConfig); +console.log('Webhook registered with ID:', webhook.id); +console.log('Secret Key:', webhook.secretKey); // Save this securely! + +// Example 2: Register with custom secret and event types +const webhookCustomConfig: WebhookConfig = { + url: 'https://your-app.com/api/custom-webhook', + secret: 'your-custom-secret', // optional - user-provided secret + events: [ + WebhookEventType.MESSAGE_SENT, + WebhookEventType.MESSAGE_RECEIVED + ] +}; +const webhookCustom = await ccai.webhook.register(webhookCustomConfig); +console.log('Custom secret webhook registered:', webhookCustom.id); + +// List all registered webhooks +const webhooks = await ccai.webhook.list(); +console.log('Registered webhooks:', webhooks.length); +webhooks.forEach(wh => { + console.log(`- ID: ${wh.id}, URL: ${wh.url}`); +}); + +// Update a webhook +const updateConfig: WebhookConfig = { + url: 'https://your-app.com/api/new-webhook', + events: [WebhookEventType.MESSAGE_SENT] +}; +const updated = await ccai.webhook.update(webhook.id, updateConfig); +console.log('Updated webhook URL:', updated.url); + +// Delete a webhook +await ccai.webhook.delete(webhook.id); + +// Verify webhook signature (in your incoming request handler) +const signature = req.headers['x-ccai-signature'] as string; +const clientId = ccai.clientId; +const eventHash = req.body.eventHash as string; // From the webhook payload +const secret = 'your-webhook-secret'; +const isValid = ccai.webhook.verifySignature(signature, clientId, eventHash, secret); + +// Parse incoming webhook event +const event = ccai.webhook.parseEvent(JSON.stringify(req.body)); +console.log('Event type:', event.eventType); // 'message.sent' | 'message.received' +console.log('Event data:', event.data); +console.log('To:', event.data.To); +console.log('From:', event.data.From); +console.log('Message:', event.data.Message); +``` #### Webhook Events @@ -132,36 +262,34 @@ CloudContactAI currently supports the following webhook events: **Message Sent Event:** ```json { - "type": "message.sent", - "campaign": { - "id": 123, - "title": "Default Campaign", - "message": "", - "senderPhone": "+11234567894", - "createdAt": "2025-07-14 22:18:28.273", - "runAt": "" - }, - "from": "+11234567894", - "to": "+11453215437", - "message": "this is a test message for Jon Doe" + "eventType": "message.sent", + "eventHash": "abc123def456ghi789", + "data": { + "To": "+15551234567", + "From": "+15551234567", + "Message": "Hello John, this is a test message", + "TotalPrice": "0.01", + "Segments": 1, + "CampaignId": "123", + "CampaignTitle": "Test Campaign" + } } ``` **Message Received Event:** ```json { - "type": "message.received", - "campaign": { - "id": 123, - "title": "Default Campaign", - "message": "", - "senderPhone": "+11234567894", - "createdAt": "2025-07-14 22:18:28.273", - "runAt": "" - }, - "from": "+11453215437", - "to": "+11234567894", - "message": "this is a reply message from Jon Doe" + "eventType": "message.received", + "eventHash": "xyz789abc123def456", + "data": { + "To": "+15551234567", + "From": "+15559876543", + "Message": "Reply from customer", + "TotalPrice": "0.01", + "Segments": 1, + "CampaignId": "123", + "CampaignTitle": "Test Campaign" + } } ``` @@ -170,30 +298,34 @@ CloudContactAI currently supports the following webhook events: ```typescript // pages/api/ccai-webhook.ts import type { NextApiRequest, NextApiResponse } from 'next'; -import { createWebhookHandler, WebhookEventType } from 'ccai-node'; +import { createWebhookHandler, WebhookEventType, type WebhookEvent } from 'ccai-node'; export default createWebhookHandler({ // Optional: Secret for verifying webhook signatures secret: process.env.CCAI_WEBHOOK_SECRET, - // Handler for outbound messages - onMessageSent: async (event) => { + // Handler for outbound messages (type-safe) + onMessageSent: async (event: WebhookEvent) => { console.log('Message sent event received:'); - console.log(`Campaign: ${event.campaign.title} (ID: ${event.campaign.id})`); - console.log(`From: ${event.from}`); - console.log(`To: ${event.to}`); - console.log(`Message: ${event.message}`); + console.log(`Event Type: ${event.eventType}`); + console.log(`Event Hash: ${event.eventHash}`); + console.log('Event Data:'); + console.log(`- To: ${event.data.To}`); + console.log(`- From: ${event.data.From}`); + console.log(`- Message: ${event.data.Message}`); + console.log(`- Campaign ID: ${event.data.CampaignId}`); // Your custom logic here }, - // Handler for inbound messages - onMessageReceived: async (event) => { + // Handler for inbound messages (type-safe) + onMessageReceived: async (event: WebhookEvent) => { console.log('Message received event received:'); - console.log(`Campaign: ${event.campaign.title} (ID: ${event.campaign.id})`); - console.log(`From: ${event.from}`); - console.log(`To: ${event.to}`); - console.log(`Message: ${event.message}`); + console.log(`Event Type: ${event.eventType}`); + console.log('Event Data:'); + console.log(`- From: ${event.data.From}`); + console.log(`- To: ${event.data.To}`); + console.log(`- Message: ${event.data.Message}`); // Your custom logic here }, @@ -205,22 +337,47 @@ export default createWebhookHandler({ #### Simple Webhook Handler -If you prefer a simpler approach, you can handle webhooks manually: +If you prefer a simpler approach, you can handle webhooks manually with type safety: ```typescript // pages/api/simple-webhook.ts import type { NextApiRequest, NextApiResponse } from 'next'; +import { CCAI, type WebhookEvent, WebhookEventType } from 'ccai-node'; + +const ccai = new CCAI({ + clientId: process.env.CCAI_CLIENT_ID!, + apiKey: process.env.CCAI_API_KEY! +}); export default (req: NextApiRequest, res: NextApiResponse) => { if (req.method === 'POST') { - const payload = req.body; + const payload = req.body as WebhookEvent; console.log('Webhook payload:', payload); + console.log('Event Type:', payload.eventType); + console.log('Event Hash:', payload.eventHash); + + // Verify signature if you have the secret + const signature = req.headers['x-ccai-signature'] as string; + const secret = process.env.CCAI_WEBHOOK_SECRET; + + if (secret && !ccai.webhook.verifySignature( + signature, + process.env.CCAI_CLIENT_ID!, + payload.eventHash, + secret + )) { + return res.status(401).json({ error: 'Invalid signature' }); + } - // Process the webhook based on its type - if (payload.type === 'message.sent') { + // Process the webhook based on its eventType (type-safe) + if (payload.eventType === WebhookEventType.MESSAGE_SENT) { // Handle outbound message event - } else if (payload.type === 'message.received') { + console.log('Message sent to:', payload.data.To); + console.log('Total Price:', payload.data.TotalPrice); + } else if (payload.eventType === WebhookEventType.MESSAGE_RECEIVED) { // Handle inbound message event + console.log('Message received from:', payload.data.From); + console.log('Message:', payload.data.Message); } // Always respond with a 200 status code @@ -275,10 +432,15 @@ async function sendMessages() { - `src/` - Source code - `ccai.ts` - Main CCAI client class - - `sms/` - SMS-related functionality + - `sms/` - SMS and MMS functionality - `sms.ts` - SMS service class - `mms.ts` - MMS service class - - `webhook/` - Webhook-related functionality + - `email/` - Email functionality + - `email.ts` - Email service class + - `contact/` - Contact management + - `contact.ts` - Contact service class (opt-out) + - `webhook/` - Webhook functionality + - `webhook.ts` - Webhook service (register, list, update, delete, verify) - `types.ts` - Type definitions for webhook events - `nextjs.ts` - Next.js integration utilities - `index.ts` - Main exports @@ -377,12 +539,14 @@ This project includes a `.gitignore` file that excludes: - TypeScript support with full type definitions - Promise-based API with async/await support -- Support for sending SMS to multiple recipients -- Support for sending MMS with images -- Upload images to S3 with signed URLs -- Webhook integration for real-time event notifications +- Send SMS to single or multiple recipients +- Send MMS with images (automatic upload to S3) +- Send Email campaigns with HTML content to single or multiple recipients +- Manage contact opt-out preferences (setDoNotText) +- Webhook management: register, list, update, delete +- Webhook signature verification (HMAC-SHA256) - Next.js API route handlers for webhook events -- Support for template variables (firstName, lastName) +- Template variable substitution (`${firstName}`, `${lastName}`) - Progress tracking via callbacks - Comprehensive error handling - Unit tests with Jest diff --git a/jest.config.js b/jest.config.js index c07d9d2..dfd1866 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,7 +8,11 @@ module.exports = { 'src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/examples/**', - '!src/**/index.ts' + '!src/**/index.ts', + '!src/*_send.ts', + '!src/test_real.ts', + '!src/webhook/types.ts', + '!src/webhook/nextjs.ts' ], coverageThreshold: { global: { diff --git a/src/__tests__/ccai.test.ts b/src/__tests__/ccai.test.ts new file mode 100644 index 0000000..cdfdf19 --- /dev/null +++ b/src/__tests__/ccai.test.ts @@ -0,0 +1,154 @@ +import axios from 'axios'; +import { CCAI } from '../ccai'; + +jest.mock('axios'); +// biome-ignore lint/suspicious/noExplicitAny: test mock requires any cast +const mockedAxios = axios as any; + +describe('CCAI Client', () => { + const validConfig = { + clientId: 'test-client-123', + apiKey: 'test-api-key-456', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create client with valid config', () => { + const ccai = new CCAI(validConfig); + expect(ccai.getClientId()).toBe('test-client-123'); + expect(ccai.getApiKey()).toBe('test-api-key-456'); + }); + + it('should use production URLs by default', () => { + const ccai = new CCAI(validConfig); + expect(ccai.getBaseUrl()).toContain('core.cloudcontactai.com'); + expect(ccai.getEmailBaseUrl()).toContain('email-campaigns.cloudcontactai.com'); + }); + + it('should use test environment URLs when useTestEnvironment is true', () => { + const ccai = new CCAI({ ...validConfig, useTestEnvironment: true }); + expect(ccai.getBaseUrl()).toContain('core-test-cloudcontactai.allcode.com'); + expect(ccai.getEmailBaseUrl()).toContain('email-campaigns-test-cloudcontactai.allcode.com'); + }); + + it('should allow custom baseUrl override', () => { + const ccai = new CCAI({ ...validConfig, baseUrl: 'https://custom.api.com' }); + expect(ccai.getBaseUrl()).toBe('https://custom.api.com'); + }); + + it('should expose sms, mms, email, webhook, contact services', () => { + const ccai = new CCAI(validConfig); + expect(ccai.sms).toBeDefined(); + expect(ccai.mms).toBeDefined(); + expect(ccai.email).toBeDefined(); + expect(ccai.webhook).toBeDefined(); + expect(ccai.contact).toBeDefined(); + }); + + it('should allow custom emailBaseUrl override', () => { + const ccai = new CCAI({ ...validConfig, emailBaseUrl: 'https://custom-email.com' }); + expect(ccai.getEmailBaseUrl()).toBe('https://custom-email.com'); + }); + + it('should allow custom filesBaseUrl override', () => { + const ccai = new CCAI({ ...validConfig, filesBaseUrl: 'https://custom-files.com' }); + expect(ccai.getFilesBaseUrl()).toBe('https://custom-files.com'); + }); + + it('should correctly report test environment status', () => { + const ccaiProd = new CCAI(validConfig); + const ccaiTest = new CCAI({ ...validConfig, useTestEnvironment: true }); + expect(ccaiProd.isTestEnvironment()).toBe(false); + expect(ccaiTest.isTestEnvironment()).toBe(true); + }); + }); + + describe('request method', () => { + it('should make a successful POST request', async () => { + mockedAxios.mockResolvedValueOnce({ + data: { success: true, id: '123' }, + }); + + const ccai = new CCAI(validConfig); + const result = await ccai.request('POST', '/test', { test: 'data' }); + + expect(result).toEqual({ success: true, id: '123' }); + expect(mockedAxios).toHaveBeenCalled(); + }); + + it('should handle axios error with response', async () => { + mockedAxios.mockRejectedValueOnce({ + response: { status: 400, data: { error: 'Bad request' } }, + }); + + const ccai = new CCAI(validConfig); + await expect(ccai.request('POST', '/test')).rejects.toThrow('API Error: 400'); + }); + + it('should handle axios error with no response', async () => { + mockedAxios.mockRejectedValueOnce({ + request: {}, + message: 'Network error', + }); + + const ccai = new CCAI(validConfig); + await expect(ccai.request('POST', '/test')).rejects.toThrow('No response received from API'); + }); + + it('should handle unknown errors', async () => { + mockedAxios.mockRejectedValueOnce(new Error('Unknown error')); + + const ccai = new CCAI(validConfig); + await expect(ccai.request('POST', '/test')).rejects.toThrow('Unknown error'); + }); + }); + + describe('customRequest method', () => { + it('should make a request to custom base URL with extra headers', async () => { + mockedAxios.mockResolvedValueOnce({ + data: { result: 'ok' }, + }); + + const ccai = new CCAI(validConfig); + const result = await ccai.customRequest('GET', '/custom', undefined, 'https://custom.com', { + 'X-Custom-Header': 'value', + }); + + expect(result).toEqual({ result: 'ok' }); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://custom.com/custom', + headers: expect.objectContaining({ + 'X-Custom-Header': 'value', + }), + }) + ); + }); + + it('should handle custom request errors with response', async () => { + mockedAxios.mockRejectedValueOnce({ + response: { status: 403, data: { error: 'Forbidden' } }, + }); + + const ccai = new CCAI(validConfig); + await expect( + ccai.customRequest('POST', '/custom', { data: 'test' }, 'https://custom.com') + ).rejects.toThrow('API Error: 403'); + }); + + it('should handle custom request errors with no response', async () => { + mockedAxios.mockRejectedValueOnce({ + request: {}, + message: 'Timeout', + }); + + const ccai = new CCAI(validConfig); + await expect(ccai.customRequest('POST', '/custom')).rejects.toThrow( + 'No response received from API' + ); + }); + }); +}); diff --git a/src/__tests__/contact.test.ts b/src/__tests__/contact.test.ts new file mode 100644 index 0000000..9cd5411 --- /dev/null +++ b/src/__tests__/contact.test.ts @@ -0,0 +1,82 @@ +import { CCAI } from '../ccai'; +import { Contact } from '../contact/contact'; + +const mockRequest = jest.fn(); + +const mockCcai = { + getClientId: () => 'client-123', + getApiKey: () => 'api-key-456', + request: mockRequest, +} as unknown as CCAI; + +const mockResponse = { contactId: 'contact-1', phone: '+15551234567', doNotText: true }; + +describe('Contact Service', () => { + let contact: Contact; + + beforeEach(() => { + jest.clearAllMocks(); + contact = new Contact(mockCcai); + mockRequest.mockResolvedValue(mockResponse); + }); + + describe('setDoNotText()', () => { + it('should opt-out a contact by phone', async () => { + const result = await contact.setDoNotText(true, undefined, '+15551234567'); + + expect(mockRequest).toHaveBeenCalledWith( + 'PUT', + '/account/do-not-text', + expect.objectContaining({ + clientId: 'client-123', + doNotText: true, + phone: '+15551234567', + }) + ); + expect(result).toEqual(mockResponse); + }); + + it('should opt-in a contact by phone', async () => { + mockRequest.mockResolvedValue({ ...mockResponse, doNotText: false }); + const result = await contact.setDoNotText(false, undefined, '+15551234567'); + + expect(mockRequest).toHaveBeenCalledWith( + 'PUT', + '/account/do-not-text', + expect.objectContaining({ doNotText: false }) + ); + expect(result.doNotText).toBe(false); + }); + + it('should opt-out a contact by contactId', async () => { + await contact.setDoNotText(true, 'contact-abc'); + + expect(mockRequest).toHaveBeenCalledWith( + 'PUT', + '/account/do-not-text', + expect.objectContaining({ + clientId: 'client-123', + doNotText: true, + contactId: 'contact-abc', + }) + ); + }); + + it('should send request without contactId or phone if neither is provided', async () => { + // Contact service does not validate — sends payload with only clientId + doNotText + const result = await contact.setDoNotText(true); + expect(mockRequest).toHaveBeenCalledWith( + 'PUT', + '/account/do-not-text', + expect.objectContaining({ clientId: 'client-123', doNotText: true }) + ); + expect(result).toBeDefined(); + }); + + it('should include clientId in all requests', async () => { + await contact.setDoNotText(true, undefined, '+15551234567'); + const payload = mockRequest.mock.calls[0][2] as Record; + expect(payload.clientId).toBe('client-123'); + }); + }); +}); diff --git a/src/__tests__/email.test.ts b/src/__tests__/email.test.ts new file mode 100644 index 0000000..4f4df22 --- /dev/null +++ b/src/__tests__/email.test.ts @@ -0,0 +1,489 @@ +import { CCAI } from '../ccai'; +import { Email } from '../email/email'; + +const mockRequest = jest.fn(); +const mockCustomRequest = jest.fn(); + +const mockCcai = { + getClientId: () => 'client-123', + getApiKey: () => 'api-key-456', + getBaseUrl: () => 'https://core.cloudcontactai.com/api', + getEmailBaseUrl: () => 'https://email-campaigns.cloudcontactai.com/api/v1', + request: mockRequest, + customRequest: mockCustomRequest, +} as unknown as CCAI; + +const mockResponse = { id: 'email-1', campaignId: 'camp-1', status: 'sent' }; +const validAccount = { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+15551234567', +}; + +describe('Email Service', () => { + let email: Email; + + beforeEach(() => { + jest.clearAllMocks(); + email = new Email(mockCcai); + mockCustomRequest.mockResolvedValue(mockResponse); + }); + + describe('send()', () => { + it('should send email campaign to multiple recipients', async () => { + const accounts = [ + validAccount, + { firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com', phone: '+15559876543' }, + ]; + const result = await email.send( + accounts, + 'Test Subject', + '

Hello!

', + 'sender@example.com', + 'reply@example.com', + 'Test Sender' + ); + + expect(mockCustomRequest).toHaveBeenCalledWith( + 'POST', + '/campaigns', + expect.objectContaining({ + subject: 'Test Subject', + message: '

Hello!

', + senderEmail: 'sender@example.com', + accounts, + }), + expect.any(String), + expect.objectContaining({ AccountId: 'client-123', ClientId: 'client-123' }) + ); + expect(result).toEqual(mockResponse); + }); + + it('should throw if accounts array is empty', async () => { + await expect( + email.send([], 'Subject', '

Hi

', 'sender@test.com', 'reply@test.com', 'Sender') + ).rejects.toThrow(); + }); + + it('should throw if subject is missing', async () => { + await expect( + email.send([validAccount], '', '

Hi

', 'sender@test.com', 'reply@test.com', 'Sender') + ).rejects.toThrow(); + }); + + it('should throw if message is missing', async () => { + await expect( + email.send([validAccount], 'Subject', '', 'sender@test.com', 'reply@test.com', 'Sender') + ).rejects.toThrow(); + }); + + it('should throw if senderEmail is missing', async () => { + await expect( + email.send([validAccount], 'Subject', '

Hi

', '', 'reply@test.com', 'Sender') + ).rejects.toThrow(); + }); + + it('should throw if replyEmail is missing', async () => { + await expect( + email.send([validAccount], 'Subject', '

Hi

', 'sender@test.com', '', 'Sender') + ).rejects.toThrow(); + }); + + it('should send email with custom senderName', async () => { + await email.send( + [validAccount], + 'Subject', + '

Hi

', + 'sender@test.com', + 'reply@test.com', + 'Custom Sender' + ); + + expect(mockCustomRequest).toHaveBeenCalledWith( + 'POST', + '/campaigns', + expect.objectContaining({ senderName: 'Custom Sender' }), + expect.any(String), + expect.any(Object) + ); + }); + }); + + describe('sendSingle()', () => { + it('should send email to a single recipient', async () => { + const result = await email.sendSingle( + 'John', + 'Doe', + 'john@example.com', + 'Welcome', + '

Hello John!

', + 'noreply@company.com', + 'support@company.com', + 'Company', + 'Welcome Email' + ); + + expect(mockCustomRequest).toHaveBeenCalledWith( + 'POST', + '/campaigns', + expect.objectContaining({ + subject: 'Welcome', + accounts: [expect.objectContaining({ email: 'john@example.com' })], + }), + expect.any(String), + expect.any(Object) + ); + expect(result).toEqual(mockResponse); + }); + + it('should throw if email is missing', async () => { + await expect( + email.sendSingle( + 'John', + 'Doe', + '', + 'Subject', + '

Hi

', + 'sender@test.com', + 'reply@test.com', + 'Sender', + 'Campaign' + ) + ).rejects.toThrow(); + }); + + it('should throw if firstName is missing', async () => { + await expect( + email.sendSingle( + '', + 'Doe', + 'john@example.com', + 'Subject', + '

Hi

', + 'sender@test.com', + 'reply@test.com', + 'Sender', + 'Campaign' + ) + ).rejects.toThrow(); + }); + }); + + describe('sendCampaign()', () => { + it('should send a full campaign object', async () => { + const campaign = { + subject: 'Newsletter', + title: 'Monthly Newsletter', + message: '

Updates!

', + senderEmail: 'news@company.com', + replyEmail: 'support@company.com', + senderName: 'Company News', + accounts: [validAccount], + campaignType: 'EMAIL' as const, + addToList: 'noList', + contactInput: 'accounts', + fromType: 'single', + senders: [], + }; + + const result = await email.sendCampaign(campaign); + expect(mockCustomRequest).toHaveBeenCalledWith( + 'POST', + '/campaigns', + expect.objectContaining({ subject: 'Newsletter' }), + expect.any(String), + expect.any(Object) + ); + expect(result).toEqual(mockResponse); + }); + + it('should send campaign with multiple senders', async () => { + const campaign = { + subject: 'Promotional Email', + title: 'Promo Campaign', + message: '

Special offer!

', + senderEmail: 'promo@company.com', + replyEmail: 'support@company.com', + senderName: 'Promo Team', + accounts: [validAccount], + campaignType: 'EMAIL' as const, + addToList: 'noList', + contactInput: 'accounts', + fromType: 'single', + senders: [{ senderEmail: 'alt@company.com', senderName: 'Alternative' }], + }; + + const result = await email.sendCampaign(campaign); + expect(mockCustomRequest).toHaveBeenCalledWith( + 'POST', + '/campaigns', + expect.objectContaining({ + subject: 'Promotional Email', + senders: expect.arrayContaining([ + expect.objectContaining({ senderEmail: 'alt@company.com' }), + ]), + }), + expect.any(String), + expect.any(Object) + ); + expect(result).toEqual(mockResponse); + }); + + it('should call onProgress callback with campaign lifecycle events', async () => { + const onProgress = jest.fn(); + const campaign = { + subject: 'Test Campaign', + title: 'Test', + message: '

Test

', + senderEmail: 'sender@test.com', + replyEmail: 'reply@test.com', + senderName: 'Sender', + accounts: [validAccount], + campaignType: 'EMAIL' as const, + addToList: 'noList', + contactInput: 'accounts', + fromType: 'single', + senders: [], + }; + + await email.sendCampaign(campaign, { onProgress }); + + expect(onProgress).toHaveBeenCalledWith('Preparing to send email campaign'); + expect(onProgress).toHaveBeenCalledWith('Sending email campaign'); + expect(onProgress).toHaveBeenCalledWith('Email campaign sent successfully'); + }); + + it('should call onProgress with error message on failure', async () => { + const onProgress = jest.fn(); + mockCustomRequest.mockRejectedValueOnce(new Error('Send failed')); + + const campaign = { + subject: 'Test Campaign', + title: 'Test', + message: '

Test

', + senderEmail: 'sender@test.com', + replyEmail: 'reply@test.com', + senderName: 'Sender', + accounts: [validAccount], + campaignType: 'EMAIL' as const, + addToList: 'noList', + contactInput: 'accounts', + fromType: 'single', + senders: [], + }; + + await expect(email.sendCampaign(campaign, { onProgress })).rejects.toThrow(); + expect(onProgress).toHaveBeenCalledWith('Email campaign sending failed'); + }); + + it('should validate account firstName, lastName, email in campaign', async () => { + const campaign = { + subject: 'Test', + title: 'Test', + message: '

Test

', + senderEmail: 'sender@test.com', + replyEmail: 'reply@test.com', + senderName: 'Sender', + accounts: [ + { + firstName: '', + lastName: 'Doe', + email: 'john@example.com', + phone: '+1234567890', + }, + ], + campaignType: 'EMAIL' as const, + addToList: 'noList', + contactInput: 'accounts', + fromType: 'single', + senders: [], + }; + + await expect(email.sendCampaign(campaign)).rejects.toThrow('First name is required'); + }); + + it('should throw if campaign title is missing', async () => { + const campaign = { + subject: 'Test', + title: '', + message: '

Test

', + senderEmail: 'sender@test.com', + replyEmail: 'reply@test.com', + senderName: 'Sender', + accounts: [validAccount], + campaignType: 'EMAIL' as const, + addToList: 'noList', + contactInput: 'accounts', + fromType: 'single', + senders: [], + }; + + await expect(email.sendCampaign(campaign)).rejects.toThrow('Campaign title is required'); + }); + + it('should throw if campaign senderName is missing', async () => { + const campaign = { + subject: 'Test', + title: 'Test', + message: '

Test

', + senderEmail: 'sender@test.com', + replyEmail: 'reply@test.com', + senderName: '', + accounts: [validAccount], + campaignType: 'EMAIL' as const, + addToList: 'noList', + contactInput: 'accounts', + fromType: 'single', + senders: [], + }; + + await expect(email.sendCampaign(campaign)).rejects.toThrow('Sender name is required'); + }); + + it('should throw if account lastName is missing in campaign', async () => { + const campaign = { + subject: 'Test', + title: 'Test', + message: '

Test

', + senderEmail: 'sender@test.com', + replyEmail: 'reply@test.com', + senderName: 'Sender', + accounts: [ + { + firstName: 'John', + lastName: '', + email: 'john@example.com', + phone: '+1234567890', + }, + ], + campaignType: 'EMAIL' as const, + addToList: 'noList', + contactInput: 'accounts', + fromType: 'single', + senders: [], + }; + + await expect(email.sendCampaign(campaign)).rejects.toThrow('Last name is required'); + }); + }); + + describe('EmailAccount custom fields', () => { + it('should send EmailAccount with customAccountId to API', async () => { + const accountWithCustomId = { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+15551234567', + customAccountId: 'ext-id-12345', + }; + + await email.send( + [accountWithCustomId], + 'Subject', + '

Hi

', + 'sender@test.com', + 'reply@test.com', + 'Sender' + ); + + expect(mockCustomRequest).toHaveBeenCalledWith( + 'POST', + '/campaigns', + expect.objectContaining({ + accounts: [expect.objectContaining({ customAccountId: 'ext-id-12345' })], + }), + expect.any(String), + expect.any(Object) + ); + }); + + it('should send EmailAccount with data field to API', async () => { + const accountWithData = { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phone: '+15551234567', + data: { tier: 'gold', locale: 'en-US' }, + }; + + await email.send( + [accountWithData], + 'Subject', + '

Hi

', + 'sender@test.com', + 'reply@test.com', + 'Sender' + ); + + expect(mockCustomRequest).toHaveBeenCalledWith( + 'POST', + '/campaigns', + expect.objectContaining({ + accounts: [expect.objectContaining({ data: { tier: 'gold', locale: 'en-US' } })], + }), + expect.any(String), + expect.any(Object) + ); + }); + + it('should map customData to messageData in email accounts (wire format)', async () => { + const accountWithCustomData = { + firstName: 'Jane', + lastName: 'Doe', + email: 'jane@example.com', + phone: '', + customData: '{"source":"email-test"}', + }; + + await email.send( + [accountWithCustomData], + 'Subject', + '

Hi

', + 'sender@test.com', + 'reply@test.com', + 'Sender' + ); + + expect(mockCustomRequest).toHaveBeenCalledWith( + 'POST', + '/campaigns', + expect.objectContaining({ + accounts: [expect.objectContaining({ messageData: '{"source":"email-test"}' })], + }), + expect.any(String), + expect.any(Object) + ); + + // customData must NOT appear in the wire payload + const sentPayload = mockCustomRequest.mock.calls[0][2] as { + accounts: Record[]; + }; + expect(sentPayload.accounts[0]?.customData).toBeUndefined(); + }); + }); + + describe('EmailResponse fields', () => { + it('should return message and responseId from API response', async () => { + mockCustomRequest.mockResolvedValueOnce({ + id: 'email-1', + campaignId: 'camp-1', + status: 'sent', + message: 'Email campaign sent successfully', + responseId: 'resp-id-xyz', + }); + + const result = await email.send( + [validAccount], + 'Subject', + '

Hi

', + 'sender@test.com', + 'reply@test.com', + 'Sender' + ); + + expect(result.message).toBe('Email campaign sent successfully'); + expect(result.responseId).toBe('resp-id-xyz'); + }); + }); +}); diff --git a/src/__tests__/mms.test.ts b/src/__tests__/mms.test.ts new file mode 100644 index 0000000..a5a7299 --- /dev/null +++ b/src/__tests__/mms.test.ts @@ -0,0 +1,286 @@ +import { CCAI } from '../ccai'; +import { MMS } from '../sms/mms'; + +jest.mock('axios'); +import axios from 'axios'; +const mockedAxios = axios as jest.Mocked; + +const mockRequest = jest.fn(); + +const mockCcai = { + getClientId: () => 'client-123', + getApiKey: () => 'api-key-456', + getBaseUrl: () => 'https://core.cloudcontactai.com/api', + getFilesBaseUrl: () => 'https://files.cloudcontactai.com', + request: mockRequest, +} as unknown as CCAI; + +const validAccount = { firstName: 'John', lastName: 'Doe', phone: '+15551234567' }; +const mockSendResponse = { id: 'mms-1', campaignId: 'camp-1', status: 'sent' }; + +describe('MMS Service', () => { + let mms: MMS; + + beforeEach(() => { + jest.clearAllMocks(); + mms = new MMS(mockCcai); + mockedAxios.post = jest.fn().mockResolvedValue({ data: mockSendResponse }); + mockedAxios.put = jest.fn().mockResolvedValue({ status: 200 }); + mockRequest.mockResolvedValue(mockSendResponse); + }); + + describe('send()', () => { + it('should send MMS with a file key via axios POST', async () => { + const result = await mms.send( + 'client-123/campaign/image.png', + [validAccount], + 'Check this out!', + 'MMS Campaign' + ); + + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://core.cloudcontactai.com/api/clients/client-123/campaigns/direct', + expect.objectContaining({ + pictureFileKey: 'client-123/campaign/image.png', + accounts: [validAccount], + message: 'Check this out!', + title: 'MMS Campaign', + }), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer api-key-456' }), + }) + ); + expect(result).toEqual(mockSendResponse); + }); + + it('should throw if accounts array is empty', async () => { + await expect(mms.send('file-key', [], 'msg', 'title')).rejects.toThrow(); + }); + + it('should throw if pictureFileKey is missing', async () => { + await expect(mms.send('', [validAccount], 'msg', 'title')).rejects.toThrow(); + }); + + it('should throw if message is missing', async () => { + await expect(mms.send('file-key', [validAccount], '', 'title')).rejects.toThrow(); + }); + + it('should throw if title is missing', async () => { + await expect(mms.send('file-key', [validAccount], 'msg', '')).rejects.toThrow(); + }); + + it('should throw if account is missing firstName', async () => { + const invalidAccount = { firstName: '', lastName: 'Doe', phone: '+15551234567' }; + await expect(mms.send('file-key', [invalidAccount], 'msg', 'title')).rejects.toThrow(); + }); + + it('should throw if account is missing lastName', async () => { + const invalidAccount = { firstName: 'John', lastName: '', phone: '+15551234567' }; + await expect(mms.send('file-key', [invalidAccount], 'msg', 'title')).rejects.toThrow(); + }); + + it('should throw if account is missing phone', async () => { + const invalidAccount = { firstName: 'John', lastName: 'Doe', phone: '' }; + await expect(mms.send('file-key', [invalidAccount], 'msg', 'title')).rejects.toThrow(); + }); + + it('should call onProgress callback when provided', async () => { + const onProgress = jest.fn(); + await mms.send('file-key', [validAccount], 'msg', 'title', undefined, { onProgress }); + expect(onProgress).toHaveBeenCalledWith('Preparing to send MMS'); + expect(onProgress).toHaveBeenCalledWith('Sending MMS'); + expect(onProgress).toHaveBeenCalledWith('MMS sent successfully'); + }); + + it('should include ForceNewCampaign header by default', async () => { + await mms.send('file-key', [validAccount], 'msg', 'title'); + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + headers: expect.objectContaining({ ForceNewCampaign: 'true' }), + }) + ); + }); + + it('should not include ForceNewCampaign header when forceNewCampaign is false', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: mockSendResponse }); + await mms.send('file-key', [validAccount], 'msg', 'title', undefined, {}, false); + const calls = mockedAxios.post.mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall).toBeDefined(); + expect(lastCall?.[2]).not.toHaveProperty('headers.ForceNewCampaign'); + }); + + it('should call onProgress with error message on failure', async () => { + const onProgress = jest.fn(); + mockedAxios.post.mockRejectedValueOnce(new Error('Network error')); + + await expect( + mms.send('file-key', [validAccount], 'msg', 'title', undefined, { onProgress }) + ).rejects.toThrow(); + expect(onProgress).toHaveBeenCalledWith('MMS sending failed'); + }); + + it('should handle axios post error gracefully', async () => { + mockedAxios.post.mockRejectedValueOnce(new Error('Request failed')); + + await expect(mms.send('file-key', [validAccount], 'msg', 'title')).rejects.toThrow( + 'Failed to send MMS' + ); + }); + + it('should map data and customData to wire format (data / messageData)', async () => { + const account = { + firstName: 'John', + lastName: 'Doe', + phone: '+15551234567', + data: { city: 'Miami', plan: 'premium' }, + customData: '{"source":"mms-test"}', + }; + + await mms.send('file-key', [account], 'Hello ${firstName} from ${city}!', 'Test'); + + const postedBody = (mockedAxios.post as jest.Mock).mock.calls[0][1]; + const sentAccount = postedBody.accounts[0]; + expect(sentAccount.data).toEqual({ city: 'Miami', plan: 'premium' }); + expect(sentAccount.messageData).toBe('{"source":"mms-test"}'); + expect(sentAccount.customData).toBeUndefined(); + }); + }); + + describe('sendSingle()', () => { + it('should send MMS to a single recipient', async () => { + const result = await mms.sendSingle( + 'client-123/campaign/image.png', + 'John', + 'Doe', + '+15551234567', + 'Hello!', + 'MMS Test' + ); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('/clients/client-123/campaigns/direct'), + expect.objectContaining({ + pictureFileKey: 'client-123/campaign/image.png', + accounts: [{ firstName: 'John', lastName: 'Doe', phone: '+15551234567' }], + }), + expect.any(Object) + ); + expect(result).toEqual(mockSendResponse); + }); + }); + + describe('getSignedUploadUrl()', () => { + it('should get signed upload URL with fileName and fileType', async () => { + const mockSignedUrlResponse = { + signedS3Url: 'https://s3.example.com/signed-url', + fileKey: 'client-123/campaign/test.jpg', + }; + mockedAxios.post.mockResolvedValue({ data: mockSignedUrlResponse }); + + const result = await mms.getSignedUploadUrl('test.jpg', 'image/jpeg'); + + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://files.cloudcontactai.com/upload/url', + expect.objectContaining({ + fileName: 'test.jpg', + fileType: 'image/jpeg', + }), + expect.any(Object) + ); + expect(result.signedS3Url).toBe('https://s3.example.com/signed-url'); + }); + + it('should throw error if fileName is missing', async () => { + await expect(mms.getSignedUploadUrl('', 'image/jpeg')).rejects.toThrow( + 'File name is required' + ); + }); + + it('should throw error if fileType is missing', async () => { + await expect(mms.getSignedUploadUrl('test.jpg', '')).rejects.toThrow('File type is required'); + }); + + it('should use custom fileBasePath when provided', async () => { + const mockSignedUrlResponse = { + signedS3Url: 'https://s3.example.com/custom-url', + fileKey: 'client-123/custom/test.jpg', + }; + mockedAxios.post.mockResolvedValue({ data: mockSignedUrlResponse }); + + await mms.getSignedUploadUrl('test.jpg', 'image/jpeg', 'custom/path'); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + fileBasePath: 'custom/path', + }), + expect.any(Object) + ); + }); + + it('should include correct headers with API key', async () => { + mockedAxios.post.mockResolvedValue({ + data: { signedS3Url: 'https://s3.example.com/url' }, + }); + + await mms.getSignedUploadUrl('test.jpg', 'image/jpeg'); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer api-key-456', + 'Content-Type': 'application/json', + }), + }) + ); + }); + + it('should throw error if response is missing signedS3Url', async () => { + mockedAxios.post.mockResolvedValue({ data: { fileKey: 'test' } }); + + await expect(mms.getSignedUploadUrl('test.jpg', 'image/jpeg')).rejects.toThrow( + 'Invalid response from upload URL API' + ); + }); + + it('should throw error if axios.post fails', async () => { + mockedAxios.post.mockRejectedValue(new Error('Network error')); + + await expect(mms.getSignedUploadUrl('test.jpg', 'image/jpeg')).rejects.toThrow( + 'Failed to get signed upload URL' + ); + }); + }); + + describe('checkFileUploaded()', () => { + it('should return stored URL response when file exists', async () => { + const storedResponse = { storedUrl: 'https://cdn.example.com/file.png' }; + mockRequest.mockResolvedValue(storedResponse); + + const result = await mms.checkFileUploaded('client-123/campaign/image.png'); + + expect(mockRequest).toHaveBeenCalledWith( + 'GET', + '/clients/client-123/storedUrl?fileKey=client-123/campaign/image.png' + ); + expect(result).toEqual(storedResponse); + }); + + it('should return object with empty storedUrl when file not found', async () => { + mockRequest.mockResolvedValue({ storedUrl: '' }); + const result = await mms.checkFileUploaded('nonexistent-key'); + expect(result).toEqual({ storedUrl: '' }); + }); + + it('should return empty storedUrl object on request error', async () => { + mockRequest.mockRejectedValue(new Error('Not found')); + const result = await mms.checkFileUploaded('bad-key'); + expect(result).toEqual({ storedUrl: '' }); + }); + }); +}); diff --git a/src/__tests__/sms.test.ts b/src/__tests__/sms.test.ts new file mode 100644 index 0000000..82b4d05 --- /dev/null +++ b/src/__tests__/sms.test.ts @@ -0,0 +1,179 @@ +import { CCAI } from '../ccai'; +import { SMS } from '../sms/sms'; + +const mockRequest = jest.fn(); + +const mockCcai = { + getClientId: () => 'client-123', + getApiKey: () => 'api-key-456', + getBaseUrl: () => 'https://core.cloudcontactai.com/api', + request: mockRequest, +} as unknown as CCAI; + +const validAccount = { firstName: 'John', lastName: 'Doe', phone: '+15551234567' }; +const mockResponse = { id: 'resp-1', campaignId: 'camp-1', status: 'sent' }; + +describe('SMS Service', () => { + let sms: SMS; + + beforeEach(() => { + jest.clearAllMocks(); + sms = new SMS(mockCcai); + mockRequest.mockResolvedValue(mockResponse); + }); + + describe('send()', () => { + it('should send SMS to multiple recipients', async () => { + const accounts = [ + validAccount, + { firstName: 'Jane', lastName: 'Smith', phone: '+15559876543' }, + ]; + const result = await sms.send(accounts, 'Hello ${firstName}!', 'Test Campaign'); + + expect(mockRequest).toHaveBeenCalledWith( + 'post', + '/clients/client-123/campaigns/direct', + expect.objectContaining({ + accounts, + message: 'Hello ${firstName}!', + title: 'Test Campaign', + }) + ); + + expect(result).toEqual(mockResponse); + }); + + it('should throw if accounts array is empty', async () => { + await expect(sms.send([], 'Hello', 'Test')).rejects.toThrow(); + }); + + it('should throw if message is missing', async () => { + await expect(sms.send([validAccount], '', 'Test')).rejects.toThrow(); + }); + + it('should throw if title is missing', async () => { + await expect(sms.send([validAccount], 'Hello', '')).rejects.toThrow(); + }); + + it('should throw if account is missing firstName', async () => { + const bad = { firstName: '', lastName: 'Doe', phone: '+15551234567' }; + await expect(sms.send([bad], 'Hello', 'Test')).rejects.toThrow(); + }); + + it('should throw if account is missing lastName', async () => { + const bad = { firstName: 'John', lastName: '', phone: '+15551234567' }; + await expect(sms.send([bad], 'Hello', 'Test')).rejects.toThrow(); + }); + + it('should throw if account is missing phone', async () => { + const bad = { firstName: 'John', lastName: 'Doe', phone: '' }; + await expect(sms.send([bad], 'Hello', 'Test')).rejects.toThrow(); + }); + + it('should call onProgress callback when provided', async () => { + const onProgress = jest.fn(); + await sms.send([validAccount], 'Hello', 'Test', undefined, { onProgress }); + expect(onProgress).toHaveBeenCalledWith('Preparing to send SMS'); + expect(onProgress).toHaveBeenCalledWith('Sending SMS'); + expect(onProgress).toHaveBeenCalledWith('SMS sent successfully'); + }); + + it('should call onProgress with error message on failure', async () => { + const onProgress = jest.fn(); + mockRequest.mockRejectedValueOnce(new Error('Send failed')); + + await expect( + sms.send([validAccount], 'Hello', 'Test', undefined, { onProgress }) + ).rejects.toThrow('Send failed'); + expect(onProgress).toHaveBeenCalledWith('SMS sending failed'); + }); + + it('should send data field in request (API wire format for variable substitution)', async () => { + // Base variables always available: ${firstName}, ${lastName}, ${phone}, ${email} + // data extends these with any additional key-value pairs the client defines + // CloudContact substitutes them in the message: "Hello ${firstName} from ${city}!" + const account = { + firstName: 'John', + lastName: 'Doe', + phone: '+15551234567', + data: { city: 'Miami', country: 'USA', plan: 'premium' }, + }; + await sms.send([account], 'Hello ${firstName} from ${city}, ${country}!', 'Test'); + + expect(mockRequest).toHaveBeenCalledWith( + 'post', + '/clients/client-123/campaigns/direct', + expect.objectContaining({ + accounts: [ + expect.objectContaining({ + data: { city: 'Miami', country: 'USA', plan: 'premium' }, + }), + ], + }) + ); + }); + + it('should send customData as messageData in request (API wire format)', async () => { + // customData is NOT used in the message — CloudContact forwards it as-is + // in the webhook payload so the client can process it on their end. + // The SDK maps customData → messageData before sending (Java @JsonProperty("messageData")). + const account = { + firstName: 'John', + lastName: 'Doe', + phone: '+15551234567', + customData: '{"orderId":"ORD-123","source":"checkout"}', + }; + await sms.send([account], 'Your order ${orderId} is ready!', 'Test'); + + expect(mockRequest).toHaveBeenCalledWith( + 'post', + '/clients/client-123/campaigns/direct', + expect.objectContaining({ + accounts: [ + expect.objectContaining({ + messageData: '{"orderId":"ORD-123","source":"checkout"}', + }), + ], + }) + ); + }); + }); + + describe('SMSResponse fields', () => { + it('should return message and responseId from API response', async () => { + mockRequest.mockResolvedValueOnce({ + id: 'resp-1', + campaignId: 'camp-1', + status: 'sent', + message: 'SMS sent successfully', + responseId: 'resp-id-abc', + }); + + const result = await sms.send([validAccount], 'Hello', 'Test'); + + expect(result.message).toBe('SMS sent successfully'); + expect(result.responseId).toBe('resp-id-abc'); + }); + }); + + describe('sendSingle()', () => { + it('should send SMS to a single recipient', async () => { + const result = await sms.sendSingle('John', 'Doe', '+15551234567', 'Hello!', 'Test'); + + expect(mockRequest).toHaveBeenCalledWith( + 'post', + '/clients/client-123/campaigns/direct', + expect.objectContaining({ + accounts: [{ firstName: 'John', lastName: 'Doe', phone: '+15551234567' }], + message: 'Hello!', + title: 'Test', + }) + ); + expect(result).toEqual(mockResponse); + }); + + it('should throw if phone is empty', async () => { + await expect(sms.sendSingle('John', 'Doe', '', 'Hello', 'Test')).rejects.toThrow(); + }); + }); +}); diff --git a/src/__tests__/webhook.test.ts b/src/__tests__/webhook.test.ts new file mode 100644 index 0000000..7921531 --- /dev/null +++ b/src/__tests__/webhook.test.ts @@ -0,0 +1,294 @@ +import * as crypto from 'crypto'; +import { CCAI } from '../ccai'; +import { Webhook } from '../webhook/webhook'; + +const mockRequest = jest.fn(); + +const mockCcai = { + getClientId: () => 'client-123', + getApiKey: () => 'api-key-456', + request: mockRequest, +} as unknown as CCAI; + +describe('Webhook Service', () => { + let webhook: Webhook; + + beforeEach(() => { + jest.clearAllMocks(); + webhook = new Webhook(mockCcai); + }); + + describe('register()', () => { + it('should register a webhook without secret - server generates it', async () => { + const responseItem = { + id: '147', + url: 'https://example.com/hook', + method: 'POST', + integrationType: 'ALL', + secretKey: 'test-secret-key-12345', + }; + mockRequest.mockResolvedValue([responseItem]); + + const result = await webhook.register({ url: 'https://example.com/hook' }); + + // Verify secretKey was NOT sent to server (allowing server to generate) + expect(mockRequest).toHaveBeenCalledWith( + 'POST', + '/v1/client/client-123/integration', + expect.arrayContaining([ + expect.objectContaining({ + url: 'https://example.com/hook', + method: 'POST', + integrationType: 'ALL', + }), + ]) + ); + const payload = mockRequest.mock.calls[0]?.[2] as Array> | undefined; + expect(payload?.[0]?.secretKey).toBeUndefined(); + + // Verify the auto-generated secret is returned + expect(result.secretKey).toBe('test-secret-key-12345'); + }); + + it('should register a webhook with custom secret', async () => { + const customSecret = 'my-custom-secret-key'; + const responseItem = { + id: '148', + url: 'https://example.com/hook', + method: 'POST', + integrationType: 'ALL', + secretKey: customSecret, + }; + mockRequest.mockResolvedValue([responseItem]); + + const result = await webhook.register({ + url: 'https://example.com/hook', + secret: customSecret, + }); + + // Verify custom secret WAS sent to server + expect(mockRequest).toHaveBeenCalledWith( + 'POST', + '/v1/client/client-123/integration', + expect.arrayContaining([ + expect.objectContaining({ + url: 'https://example.com/hook', + secretKey: customSecret, + }), + ]) + ); + + // Verify the custom secret is returned + expect(result.secretKey).toBe(customSecret); + }); + + it('should register a webhook with secretKey field', async () => { + const customSecret = 'test-secret-key-67890'; + const responseItem = { + id: '149', + url: 'https://example.com/hook', + method: 'POST', + integrationType: 'ALL', + secretKey: customSecret, + }; + mockRequest.mockResolvedValue([responseItem]); + + const result = await webhook.register({ + url: 'https://example.com/hook', + secretKey: customSecret, + }); + + // Verify secretKey field is used + expect(mockRequest).toHaveBeenCalledWith( + 'POST', + '/v1/client/client-123/integration', + expect.arrayContaining([ + expect.objectContaining({ + url: 'https://example.com/hook', + secretKey: customSecret, + }), + ]) + ); + expect(result.secretKey).toBe(customSecret); + }); + + it('should default integrationType to ALL', async () => { + mockRequest.mockResolvedValue([ + { id: '1', url: 'https://test.com', method: 'POST', integrationType: 'ALL' }, + ]); + await webhook.register({ url: 'https://test.com' }); + + expect(mockRequest).toHaveBeenCalledWith( + 'POST', + '/v1/client/client-123/integration', + expect.arrayContaining([ + expect.objectContaining({ + integrationType: 'ALL', + }), + ]) + ); + }); + + it('should handle non-array response from API', async () => { + const nonArrayResponse = { + id: '999', + url: 'https://example.com/hook', + method: 'POST', + integrationType: 'SMS', + secretKey: 'sk_test_123', + }; + mockRequest.mockResolvedValue(nonArrayResponse); + + const result = await webhook.register({ url: 'https://example.com/hook' }); + + expect(result).toEqual(nonArrayResponse); + expect(result.id).toBe('999'); + }); + }); + + describe('update()', () => { + it('should update webhook using POST with id in array payload', async () => { + const responseItem = { + id: '42', + url: 'https://new.com/hook', + method: 'POST', + integrationType: 'ALL', + }; + mockRequest.mockResolvedValue([responseItem]); + + const result = await webhook.update('42', { url: 'https://new.com/hook' }); + + expect(mockRequest).toHaveBeenCalledWith( + 'POST', + '/v1/client/client-123/integration', + expect.arrayContaining([expect.objectContaining({ id: 42, url: 'https://new.com/hook' })]) + ); + expect(result).toEqual(responseItem); + }); + + it('should include secretKey when updating with custom secret', async () => { + const responseItem = { + id: '42', + url: 'https://new.com/hook', + method: 'POST', + integrationType: 'ALL', + secretKey: 'custom-secret-123', + }; + mockRequest.mockResolvedValue([responseItem]); + + await webhook.update('42', { url: 'https://new.com/hook', secret: 'custom-secret-123' }); + + expect(mockRequest).toHaveBeenCalledWith( + 'POST', + '/v1/client/client-123/integration', + expect.arrayContaining([ + expect.objectContaining({ + id: 42, + url: 'https://new.com/hook', + secretKey: 'custom-secret-123', + }), + ]) + ); + }); + + it('should handle non-array response from API in update', async () => { + const nonArrayResponse = { + id: '42', + url: 'https://new.com/hook', + method: 'POST', + integrationType: 'ALL', + }; + mockRequest.mockResolvedValue(nonArrayResponse); + + const result = await webhook.update('42', { url: 'https://new.com/hook' }); + + expect(result).toEqual(nonArrayResponse); + }); + }); + + describe('list()', () => { + it('should list all webhooks via GET', async () => { + const webhooks = [ + { id: '1', url: 'https://a.com', method: 'POST', integrationType: 'ALL' }, + { id: '2', url: 'https://b.com', method: 'POST', integrationType: 'SMS' }, + ]; + mockRequest.mockResolvedValue(webhooks); + + const result = await webhook.list(); + + expect(mockRequest).toHaveBeenCalledWith('GET', '/v1/client/client-123/integration'); + expect(result).toEqual(webhooks); + }); + }); + + describe('delete()', () => { + it('should delete a webhook by id via DELETE', async () => { + mockRequest.mockResolvedValue({ success: true, message: 'deleted' }); + + const result = await webhook.delete('42'); + + expect(mockRequest).toHaveBeenCalledWith('DELETE', '/v1/client/client-123/integration/42'); + expect(result).toEqual({ success: true, message: 'deleted' }); + }); + }); + + describe('verifySignature()', () => { + const clientId = 'client-123'; + const eventHash = 'event-hash-abc123'; + const secret = 'test-webhook-secret'; + + function computeHmac(clientId: string | number, eventHash: string, key: string): string { + const data = `${clientId}:${eventHash}`; + return crypto.createHmac('sha256', key).update(data).digest('base64'); + } + + it('should return true for a valid signature', () => { + const validSig = computeHmac(clientId, eventHash, secret); + expect(webhook.verifySignature(validSig, clientId, eventHash, secret)).toBe(true); + }); + + it('should return false for an invalid signature', () => { + expect(webhook.verifySignature('invalidsig', clientId, eventHash, secret)).toBe(false); + }); + + it('should return false when signature is empty', () => { + expect(webhook.verifySignature('', clientId, eventHash, secret)).toBe(false); + }); + + it('should return false when clientId is missing', () => { + const validSig = computeHmac(clientId, eventHash, secret); + expect(webhook.verifySignature(validSig, '', eventHash, secret)).toBe(false); + }); + + it('should return false when eventHash is missing', () => { + const validSig = computeHmac(clientId, eventHash, secret); + expect(webhook.verifySignature(validSig, clientId, '', secret)).toBe(false); + }); + + it('should return false when secret is empty', () => { + const validSig = computeHmac(clientId, eventHash, secret); + expect(webhook.verifySignature(validSig, clientId, eventHash, '')).toBe(false); + }); + }); + + describe('parseEvent()', () => { + it('should parse a valid webhook event JSON', () => { + const payload = JSON.stringify({ + eventType: 'message.sent', + eventHash: 'hash-abc123', + data: { + To: '+15551234567', + Message: 'Hello', + }, + }); + + const event = webhook.parseEvent(payload); + expect(event).toBeDefined(); + expect(event.data.To).toBe('+15551234567'); + }); + + it('should throw on invalid JSON', () => { + expect(() => webhook.parseEvent('not-json')).toThrow(); + }); + }); +}); diff --git a/src/ccai.ts b/src/ccai.ts index 1973a62..dcc427b 100644 --- a/src/ccai.ts +++ b/src/ccai.ts @@ -1,60 +1,165 @@ /** * ccai.ts - A TypeScript module for interacting with the Cloud Contact AI API - * This module provides functionality to send SMS messages and email campaigns through the CCAI platform. - * + * This module provides functionality to send SMS, MMS, and Email messages, + * manage webhooks, and handle contact preferences through the CCAI platform. + * * @license MIT * @copyright 2025 CloudContactAI LLC */ import axios, { AxiosResponse } from 'axios'; -import { SMS } from './sms/sms'; -import { MMS } from './sms/mms'; +import { Contact } from './contact/contact'; import { Email } from './email/email'; +import { MMS } from './sms/mms'; +import { SMS } from './sms/sms'; import { Webhook } from './webhook/webhook'; -// Define types for type safety +// Production URLs +const PROD_BASE_URL = 'https://core.cloudcontactai.com/api'; +const PROD_EMAIL_URL = 'https://email-campaigns.cloudcontactai.com/api/v1'; +const PROD_FILES_URL = 'https://files.cloudcontactai.com'; + +// Test environment URLs +const TEST_BASE_URL = 'https://core-test-cloudcontactai.allcode.com/api'; +const TEST_EMAIL_URL = 'https://email-campaigns-test-cloudcontactai.allcode.com/api/v1'; +const TEST_FILES_URL = 'https://files-test-cloudcontactai.allcode.com'; + +/** + * Account representing a message recipient. + * + * Base fields always available as template variables in messages: + * ${firstName}, ${lastName}, ${phone}, ${email} + */ export type Account = { firstName: string; lastName: string; phone: string; + /** + * Additional key-value pairs for variable substitution in message templates. + * Define any keys you want and use them as ${key} in your message. + * + * Example: + * data: { city: "Miami", country: "USA" } + * message: "Hello ${firstName}, greetings from ${city}, ${country}!" + * + * CloudContact substitutes all matching variables automatically. + */ + data?: Record; + /** + * Arbitrary string payload forwarded as-is to your webhook handler. + * Not used in the message body — useful for passing context (e.g. order ID, session data) + * that your system needs when processing the webhook event. + * + * Example: '{"orderId":"ORD-123","source":"checkout"}' + * + * Note: sent to the API as `messageData` (wire format) — the SDK handles the mapping. + */ + customData?: string; }; +/** + * Configuration options for the CCAI client + */ export type CCAIConfig = { + /** Client ID for authentication */ clientId: string; + /** API key for authentication */ apiKey: string; + /** Whether to use test environment URLs (default: false) */ + useTestEnvironment?: boolean; + /** Override base URL for the core API */ baseUrl?: string; + /** Override base URL for the Email API */ + emailBaseUrl?: string; + /** Override base URL for the Files API */ + filesBaseUrl?: string; }; +/** + * Main client for interacting with the CloudContactAI API + */ export class CCAI { private clientId: string; private apiKey: string; private baseUrl: string; + private emailBaseUrl: string; + private filesBaseUrl: string; + private useTestEnvironment: boolean; + + /** SMS service for sending text messages */ public sms: SMS; + + /** MMS service for sending multimedia messages */ public mms: MMS; + + /** Email service for sending email campaigns */ public email: Email; + + /** Webhook service for managing webhook endpoints */ public webhook: Webhook; + /** Contact service for managing contact preferences */ + public contact: Contact; + /** * Create a new CCAI client instance - * @param config - Configuration object containing clientId and apiKey + * @param config - Configuration object */ constructor(config: CCAIConfig) { if (!config.clientId) throw new Error('Client ID is required'); if (!config.apiKey) throw new Error('API Key is required'); - + this.clientId = config.clientId; this.apiKey = config.apiKey; - this.baseUrl = config.baseUrl || 'https://core-test-cloudcontactai.allcode.com/api'; - + this.useTestEnvironment = config.useTestEnvironment ?? false; + + // Resolve URLs: explicit override > env var > test/prod default + this.baseUrl = this.resolveUrl( + config.baseUrl, + process.env.CCAI_BASE_URL, + PROD_BASE_URL, + TEST_BASE_URL + ); + + this.emailBaseUrl = this.resolveUrl( + config.emailBaseUrl, + process.env.CCAI_EMAIL_BASE_URL, + PROD_EMAIL_URL, + TEST_EMAIL_URL + ); + + this.filesBaseUrl = this.resolveUrl( + config.filesBaseUrl, + process.env.CCAI_FILES_BASE_URL, + PROD_FILES_URL, + TEST_FILES_URL + ); + // Initialize the services this.sms = new SMS(this); this.mms = new MMS(this); this.email = new Email(this); this.webhook = new Webhook(this); + this.contact = new Contact(this); + } + + /** + * Resolve URL with priority: explicit > env > prod/test default + */ + private resolveUrl( + explicit: string | undefined, + envVar: string | undefined, + prodDefault: string, + testDefault: string + ): string { + if (explicit) return explicit; + if (envVar) return envVar; + return this.useTestEnvironment ? testDefault : prodDefault; } /** * Get the client ID + * @returns The client ID */ getClientId(): string { return this.clientId; @@ -62,117 +167,126 @@ export class CCAI { /** * Get the API key + * @returns The API key */ getApiKey(): string { return this.apiKey; } /** - * Get the base URL + * Get the base URL for the core API + * @returns The base URL */ getBaseUrl(): string { return this.baseUrl; } + /** + * Get the base URL for the Email API + * @returns The email base URL + */ + getEmailBaseUrl(): string { + return this.emailBaseUrl; + } + + /** + * Get the base URL for the Files API + * @returns The files base URL + */ + getFilesBaseUrl(): string { + return this.filesBaseUrl; + } + + /** + * Whether the test environment is active + * @returns True if using test environment + */ + isTestEnvironment(): boolean { + return this.useTestEnvironment; + } + /** * Make an authenticated API request to the CCAI API - * @param method - HTTP method - * @param endpoint - API endpoint - * @param data - Request data + * @param method - HTTP method (get, post, put, delete) + * @param endpoint - API endpoint (relative to base URL) + * @param data - Request body data * @returns Promise resolving to the API response */ async request(method: string, endpoint: string, data?: unknown): Promise { const url = `${this.baseUrl}${endpoint}`; - + try { const response: AxiosResponse = await axios({ method, url, headers: { - 'Authorization': `Bearer ${this.apiKey}`, + Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', - 'Accept': '*/*' + Accept: '*/*', }, - data + data, }); - + return response.data; } catch (error: unknown) { if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as { response: { status: number; data: unknown } }; - throw new Error(`API Error: ${axiosError.response.status} - ${JSON.stringify(axiosError.response.data)}`); - } else if (error && typeof error === 'object' && 'request' in error) { + throw new Error( + `API Error: ${axiosError.response.status} - ${JSON.stringify(axiosError.response.data)}` + ); + } + if (error && typeof error === 'object' && 'request' in error) { throw new Error('No response received from API'); - } else { - throw error; } + throw error; } } /** * Make an authenticated API request to a custom API endpoint - * @param method - HTTP method - * @param endpoint - API endpoint - * @param data - Request data - * @param customBaseUrl - Custom base URL for the API + * @param method - HTTP method (get, post, put, delete) + * @param endpoint - API endpoint (relative to custom base URL) + * @param data - Request body data + * @param customBaseUrl - Custom base URL (defaults to core base URL) + * @param extraHeaders - Additional headers to merge with defaults * @returns Promise resolving to the API response */ - async customRequest(method: string, endpoint: string, data?: unknown, customBaseUrl?: string): Promise { + async customRequest( + method: string, + endpoint: string, + data?: unknown, + customBaseUrl?: string, + extraHeaders?: Record + ): Promise { const url = `${customBaseUrl || this.baseUrl}${endpoint}`; - - // Log equivalent curl command - const curlCommand = this.generateCurlCommand(method, url, data); - console.log('\n📡 Equivalent curl command:'); - console.log(curlCommand); - console.log(''); - + + const headers: Record = { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + Accept: '*/*', + ...extraHeaders, + }; + try { const response: AxiosResponse = await axios({ method, url, - headers: { - 'Authorization': `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json', - 'Accept': '*/*' - }, - data + headers, + data, }); - + return response.data; } catch (error: unknown) { if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as { response: { status: number; data: unknown } }; - throw new Error(`API Error: ${axiosError.response.status} - ${JSON.stringify(axiosError.response.data)}`); - } else if (error && typeof error === 'object' && 'request' in error) { + throw new Error( + `API Error: ${axiosError.response.status} - ${JSON.stringify(axiosError.response.data)}` + ); + } + if (error && typeof error === 'object' && 'request' in error) { throw new Error('No response received from API'); - } else { - throw error; } + throw error; } } - - /** - * Generate equivalent curl command for debugging - * @param method - HTTP method - * @param url - Full URL - * @param data - Request data - * @returns Curl command string - */ - private generateCurlCommand(method: string, url: string, data?: unknown): string { - let curl = `curl -X ${method.toUpperCase()} "${url}"`; - curl += ` \\ - -H "Authorization: Bearer ${this.apiKey}"`; - curl += ` \\ - -H "Content-Type: application/json"`; - curl += ` \\ - -H "Accept: */*"`; - - if (data) { - const jsonData = JSON.stringify(data, null, 2).replace(/"/g, '\\"'); - curl += ` \\ - -d "${jsonData}"`; - } - - return curl; - } } diff --git a/src/contact/contact.ts b/src/contact/contact.ts new file mode 100644 index 0000000..84262c4 --- /dev/null +++ b/src/contact/contact.ts @@ -0,0 +1,65 @@ +/** + * contact.ts - A TypeScript module for managing contact preferences via CloudContactAI + * + * @license MIT + * @copyright 2025 CloudContactAI LLC + */ + +import { CCAI } from '../ccai'; + +/** + * Response from the setDoNotText API + */ +export type SetDoNotTextResponse = { + /** Contact ID */ + contactId?: string; + /** Phone number */ + phone?: string; + /** Whether the contact is opted out of text messages */ + doNotText?: boolean; + /** Additional data from the API */ + [key: string]: unknown; +}; + +/** + * Service for managing contact preferences (opt-out) + */ +export class Contact { + private ccai: CCAI; + + /** + * Create a new Contact service instance + * @param ccai - The parent CCAI instance + */ + constructor(ccai: CCAI) { + this.ccai = ccai; + } + + /** + * Set the do-not-text preference for a contact + * @param doNotText - True to opt out, false to opt in + * @param contactId - Contact ID (optional if phone is provided) + * @param phone - Phone number in E.164 format (optional if contactId is provided) + * @returns Promise resolving to the API response + */ + async setDoNotText( + doNotText: boolean, + contactId?: string, + phone?: string + ): Promise { + const payload: Record = { + clientId: this.ccai.getClientId(), + doNotText, + }; + + if (contactId !== undefined) { + payload.contactId = contactId; + } + + if (phone !== undefined) { + payload.phone = phone; + } + + return this.ccai.request('PUT', '/account/do-not-text', payload); + } +} diff --git a/src/email/email.ts b/src/email/email.ts index b48e6c1..76aeb85 100644 --- a/src/email/email.ts +++ b/src/email/email.ts @@ -1,28 +1,36 @@ /** * email.ts - Email service for the CCAI API * Handles sending email campaigns through the Cloud Contact AI platform. - * + * * @license MIT * @copyright 2025 CloudContactAI LLC */ -import { CCAI, Account } from '../ccai'; +import { Account, CCAI } from '../ccai'; +/** + * Email recipient account + */ export type EmailAccount = Account & { email: string; + customAccountId?: string; }; +/** + * Email campaign configuration + */ export type EmailCampaign = { subject: string; title: string; message: string; + textContent?: string | null; editor?: string | null; fileKey?: string | null; senderEmail: string; replyEmail: string; senderName: string; accounts: EmailAccount[]; - campaignType: "EMAIL"; + campaignType: 'EMAIL'; scheduledTimestamp?: string | null; scheduledTimezone?: string | null; addToList: string; @@ -33,39 +41,40 @@ export type EmailCampaign = { emailTemplateId?: string | null; fluxId?: string | null; fromType: string; - senders: any[]; + senders: Record[]; }; +/** + * Email API response + */ export type EmailResponse = { - // Define the expected response structure from the API id?: string; status?: string; campaignId?: string; + message?: string; + responseId?: string; messagesSent?: number; timestamp?: string; [key: string]: unknown; }; +/** + * Optional settings for email operations + */ export type EmailOptions = { - /** - * Optional timeout in milliseconds - */ + /** Request timeout in milliseconds */ timeout?: number; - - /** - * Optional retry count for failed requests - */ + /** Retry count for failed requests */ retries?: number; - - /** - * Optional callback for tracking progress - */ + /** Callback for tracking progress */ onProgress?: (status: string) => void; }; +/** + * Service for sending email campaigns through the CCAI API + */ export class Email { private ccai: CCAI; - private baseUrl: string = 'https://email-campaigns-test-cloudcontactai.allcode.com/api/v1'; /** * Create a new Email service instance @@ -76,104 +85,122 @@ export class Email { } /** - * Make an authenticated API request to the email campaigns API with required headers + * Make an authenticated API request to the email campaigns API + * Uses the email base URL with AccountId and ClientId headers * @param method - HTTP method * @param endpoint - API endpoint - * @param data - Request data + * @param data - Request body data * @returns Promise resolving to the API response */ private async makeEmailRequest(method: string, endpoint: string, data?: unknown): Promise { - const url = `${this.baseUrl}${endpoint}`; - - try { - const axios = (await import('axios')).default; - const response = await axios({ - method, - url, - headers: { - 'Authorization': `Bearer ${this.ccai.getApiKey()}`, - 'Content-Type': 'application/json', - 'Accept': '*/*', - 'clientId': this.ccai.getClientId(), - 'accountId': '1223' // This should be configurable in the future - }, - data - }); - - return response.data; - } catch (error: unknown) { - if (error && typeof error === 'object' && 'response' in error) { - const axiosError = error as { response: { status: number; data: unknown } }; - throw new Error(`API Error: ${axiosError.response.status} - ${JSON.stringify(axiosError.response.data)}`); - } else if (error && typeof error === 'object' && 'request' in error) { - throw new Error('No response received from API'); - } else { - throw error; - } - } + return this.ccai.customRequest(method, endpoint, data, this.ccai.getEmailBaseUrl(), { + AccountId: this.ccai.getClientId(), + ClientId: this.ccai.getClientId(), + }); } /** * Send an email campaign to one or more recipients - * @param campaign - The email campaign configuration - * @param options - Optional settings for the email send operation + * @param accounts - Array of recipient accounts + * @param subject - Email subject line + * @param message - The HTML message content + * @param senderEmail - Sender's email address + * @param replyEmail - Reply-to email address + * @param senderName - Sender's display name + * @param title - Campaign title (defaults to subject) + * @param options - Optional settings for progress tracking * @returns Promise resolving to the API response */ - async sendCampaign( - campaign: EmailCampaign, + async send( + accounts: EmailAccount[], + subject: string, + message: string, + senderEmail: string, + replyEmail: string, + senderName: string, + title?: string, options?: EmailOptions ): Promise { + const campaignTitle = title || subject; + + const campaign: EmailCampaign = { + subject, + title: campaignTitle, + message, + senderEmail, + replyEmail, + senderName, + accounts, + campaignType: 'EMAIL', + addToList: 'noList', + contactInput: 'accounts', + fromType: 'single', + senders: [], + }; + + return this.sendCampaign(campaign, options); + } + + /** + * Send an email campaign with full campaign configuration + * @param campaign - The complete email campaign configuration + * @param options - Optional settings for progress tracking + * @returns Promise resolving to the API response + */ + async sendCampaign(campaign: EmailCampaign, options?: EmailOptions): Promise { // Validate inputs if (!campaign.accounts || !Array.isArray(campaign.accounts) || campaign.accounts.length === 0) { throw new Error('At least one account is required'); } - + if (!campaign.subject) throw new Error('Subject is required'); if (!campaign.title) throw new Error('Campaign title is required'); if (!campaign.message) throw new Error('Message content is required'); if (!campaign.senderEmail) throw new Error('Sender email is required'); if (!campaign.replyEmail) throw new Error('Reply email is required'); if (!campaign.senderName) throw new Error('Sender name is required'); - + // Validate each account has the required fields campaign.accounts.forEach((account, index) => { - if (!account.firstName) throw new Error(`First name is required for account at index ${index}`); + if (!account.firstName) + throw new Error(`First name is required for account at index ${index}`); if (!account.lastName) throw new Error(`Last name is required for account at index ${index}`); if (!account.email) throw new Error(`Email is required for account at index ${index}`); }); - + // Notify progress if callback provided if (options?.onProgress) { options.onProgress('Preparing to send email campaign'); } - + const endpoint = '/campaigns'; - + + // Map customData → messageData (API wire format) + const mappedAccounts = campaign.accounts.map(({ data, customData, ...rest }) => ({ + ...rest, + ...(data !== undefined ? { data } : {}), + ...(customData !== undefined ? { messageData: customData } : {}), + })); + + const payload = { ...campaign, accounts: mappedAccounts }; + try { - // Notify progress if callback provided if (options?.onProgress) { options.onProgress('Sending email campaign'); } - - // Make the API request to the email campaigns API with custom headers - const response = await this.makeEmailRequest( - 'POST', - endpoint, - campaign - ); - - // Notify progress if callback provided + + const response = await this.makeEmailRequest('POST', endpoint, payload); + if (options?.onProgress) { options.onProgress('Email campaign sent successfully'); } - + return response; } catch (error) { - // Notify progress if callback provided if (options?.onProgress) { options.onProgress('Email campaign sending failed'); } - + throw error; } } @@ -183,13 +210,13 @@ export class Email { * @param firstName - Recipient's first name * @param lastName - Recipient's last name * @param email - Recipient's email address - * @param subject - Email subject + * @param subject - Email subject line * @param message - The HTML message content * @param senderEmail - Sender's email address * @param replyEmail - Reply-to email address - * @param senderName - Sender's name + * @param senderName - Sender's display name * @param title - Campaign title - * @param options - Optional settings for the email send operation + * @param options - Optional settings for progress tracking * @returns Promise resolving to the API response */ async sendSingle( @@ -198,34 +225,36 @@ export class Email { email: string, subject: string, message: string, - senderEmail: string, - replyEmail: string, - senderName: string, - title: string, + textContent?: string, + senderEmail = 'noreply@cloudcontactai.com', + replyEmail = 'noreply@cloudcontactai.com', + senderName = 'CloudContactAI', + title: string = subject, options?: EmailOptions ): Promise { const account: EmailAccount = { firstName, lastName, email, - phone: '' // Required by Account type but not used for email + phone: '', // Required by Account type but not used for email }; - + const campaign: EmailCampaign = { subject, - title, + title: title || subject, message, + ...(textContent ? { textContent } : {}), senderEmail, replyEmail, senderName, accounts: [account], - campaignType: "EMAIL", - addToList: "noList", - contactInput: "accounts", - fromType: "single", - senders: [] + campaignType: 'EMAIL', + addToList: 'noList', + contactInput: 'accounts', + fromType: 'single', + senders: [], }; - + return this.sendCampaign(campaign, options); } } diff --git a/src/email_send.ts b/src/email_send.ts index e735a32..c05e92d 100644 --- a/src/email_send.ts +++ b/src/email_send.ts @@ -9,15 +9,15 @@ const ccai = new CCAI({ async function sendEmail() { try { const response = await ccai.email.sendSingle( - "Andreas", - "Doe", - "andreas@allcode.com", - "Test Email Subject", - "

Hello ${firstName},

This is a test email.

Thanks,
AllCode Team

", - "noreply@allcode.com", - "support@allcode.com", - "AllCode Team", - "Email Test Campaign" + 'Andreas', + 'Doe', + 'andreas@allcode.com', + 'Test Email Subject', + '

Hello ${firstName},

This is a test email.

Thanks,
AllCode Team

', + 'noreply@allcode.com', + 'support@allcode.com', + 'AllCode Team', + 'Email Test Campaign' ); console.log('Email sent successfully:', response); } catch (error) { diff --git a/src/examples/async-example.ts b/src/examples/async-example.ts index fde4cdb..22ce26c 100644 --- a/src/examples/async-example.ts +++ b/src/examples/async-example.ts @@ -1,6 +1,6 @@ /** * Advanced example using async/await with progress tracking - * + * * @license MIT * @copyright 2025 CloudContactAI LLC */ @@ -15,21 +15,21 @@ dotenv.config(); // Create a new CCAI client const ccai = new CCAI({ clientId: process.env.CCAI_CLIENT_ID || '', - apiKey: process.env.CCAI_API_KEY || '' + apiKey: process.env.CCAI_API_KEY || '', }); // Example recipients const accounts: Account[] = [ { - firstName: "John", - lastName: "Doe", - phone: "+15551234567" + firstName: 'John', + lastName: 'Doe', + phone: '+15551234567', }, { - firstName: "Jane", - lastName: "Smith", - phone: "+15559876543" - } + firstName: 'Jane', + lastName: 'Smith', + phone: '+15559876543', + }, ]; // Options for SMS sending with progress tracking @@ -38,7 +38,7 @@ const options: SMSOptions = { retries: 2, onProgress: (status: string) => { console.log(`Progress: ${status}`); - } + }, }; /** @@ -47,18 +47,19 @@ const options: SMSOptions = { async function sendMessagesWithTracking() { try { console.log('Starting SMS campaign...'); - + // Send campaign with progress tracking const response = await ccai.sms.send( accounts, - "Hello ${firstName}, this is a tracked message!", - "Tracked Campaign", + 'Hello ${firstName}, this is a tracked message!', + 'Tracked Campaign', + undefined, options ); - + console.log('Campaign completed successfully!'); console.log('Response:', JSON.stringify(response, null, 2)); - + return response; } catch (error: unknown) { if (error instanceof Error) { @@ -75,32 +76,32 @@ async function sendMessagesWithTracking() { */ async function sendSequentialMessages() { const results: SMSResponse[] = []; - + try { // Send first message console.log('Sending first message...'); const firstResponse = await ccai.sms.sendSingle( - "Alex", - "Johnson", - "+15551112222", - "Hi ${firstName}, this is message 1!", - "Sequential Test 1" + 'Alex', + 'Johnson', + '+15551112222', + 'Hi ${firstName}, this is message 1!', + 'Sequential Test 1' ); results.push(firstResponse); console.log('First message sent successfully!'); - + // Send second message only if first one succeeds console.log('Sending second message...'); const secondResponse = await ccai.sms.sendSingle( - "Maria", - "Garcia", - "+15553334444", - "Hi ${firstName}, this is message 2!", - "Sequential Test 2" + 'Maria', + 'Garcia', + '+15553334444', + 'Hi ${firstName}, this is message 2!', + 'Sequential Test 2' ); results.push(secondResponse); console.log('Second message sent successfully!'); - + return results; } catch (error: unknown) { if (error instanceof Error) { @@ -108,7 +109,7 @@ async function sendSequentialMessages() { } else { console.error('Sequential sending failed with unknown error'); } - + // Return partial results if any if (results.length > 0) { console.log(`Successfully sent ${results.length} messages before failure`); @@ -123,12 +124,12 @@ async function runExamples() { try { // Run the tracked example await sendMessagesWithTracking(); - + console.log('\n-----------------------------------\n'); - + // Run the sequential example await sendSequentialMessages(); - + console.log('\nAll examples completed successfully!'); } catch (error: unknown) { if (error instanceof Error) { diff --git a/src/examples/basic-example.ts b/src/examples/basic-example.ts index e24e5f3..8b8648d 100644 --- a/src/examples/basic-example.ts +++ b/src/examples/basic-example.ts @@ -15,21 +15,21 @@ dotenv.config(); // Create a new CCAI client const ccai = new CCAI({ clientId: process.env.CCAI_CLIENT_ID || '', - apiKey: process.env.CCAI_API_KEY || '' + apiKey: process.env.CCAI_API_KEY || '', }); // Example recipients const accounts: Account[] = [ { - firstName: "John", - lastName: "Doe", - phone: "+15551234567" // Use E.164 format - } + firstName: 'John', + lastName: 'Doe', + phone: '+15551234567', // Use E.164 format + }, ]; // Message with variable placeholders -const message = "Hello ${firstName} ${lastName}, this is a test message!"; -const title = "Test Campaign"; +const message = 'Hello ${firstName} ${lastName}, this is a test message!'; +const title = 'Test Campaign'; /** * Example of sending SMS messages using async/await @@ -38,22 +38,18 @@ async function sendMessages() { try { // Method 1: Send SMS to multiple recipients console.log('Sending campaign to multiple recipients...'); - const campaignResponse: SMSResponse = await ccai.sms.send( - accounts, - message, - title - ); + const campaignResponse: SMSResponse = await ccai.sms.send(accounts, message, title); console.log('SMS campaign sent successfully!'); console.log(campaignResponse); // Method 2: Send SMS to a single recipient console.log('\nSending message to a single recipient...'); const singleResponse: SMSResponse = await ccai.sms.sendSingle( - "Jane", - "Smith", - "+15559876543", - "Hi ${firstName}, thanks for your interest!", - "Single Message Test" + 'Jane', + 'Smith', + '+15559876543', + 'Hi ${firstName}, thanks for your interest!', + 'Single Message Test' ); console.log('Single SMS sent successfully!'); console.log(singleResponse); @@ -71,9 +67,9 @@ async function sendMessages() { // Execute the async function sendMessages() - .then(results => { + .then((results) => { console.log('\nAll messages sent successfully!'); - console.log('\nResults ' + results.toString()); + console.log(`\nResults ${results.toString()}`); }) .catch(() => { console.error('\nFailed to send one or more messages.'); diff --git a/src/examples/email-examples.ts b/src/examples/email-examples.ts index fd37ff7..9bb9ff8 100644 --- a/src/examples/email-examples.ts +++ b/src/examples/email-examples.ts @@ -27,7 +27,7 @@ async function sendSingleEmail() { 'AllCode', 'Welcome Email' ); - + console.log('Email sent successfully:', response); } catch (error) { console.error('Error sending email:', error); @@ -62,32 +62,32 @@ async function sendEmailCampaign() { firstName: 'John', lastName: 'Doe', email: 'john@example.com', - phone: '' + phone: '', }, { firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com', - phone: '' + phone: '', }, { firstName: 'Bob', lastName: 'Johnson', email: 'bob@example.com', - phone: '' - } + phone: '', + }, ], campaignType: 'EMAIL', addToList: 'noList', contactInput: 'accounts', fromType: 'single', - senders: [] + senders: [], }; - + const response = await ccai.email.sendCampaign(campaign, { - onProgress: (status) => console.log(`Status: ${status}`) + onProgress: (status) => console.log(`Status: ${status}`), }); - + console.log('Email campaign sent successfully:', response); } catch (error) { console.error('Error sending email campaign:', error); @@ -103,7 +103,7 @@ async function scheduleEmailCampaign() { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(10, 0, 0, 0); - + const campaign: EmailCampaign = { subject: 'Upcoming Event Reminder', title: 'Event Reminder Campaign', @@ -122,8 +122,8 @@ async function scheduleEmailCampaign() { firstName: 'John', lastName: 'Doe', email: 'john@example.com', - phone: '' - } + phone: '', + }, ], campaignType: 'EMAIL', scheduledTimestamp: tomorrow.toISOString(), @@ -131,11 +131,11 @@ async function scheduleEmailCampaign() { addToList: 'noList', contactInput: 'accounts', fromType: 'single', - senders: [] + senders: [], }; - + const response = await ccai.email.sendCampaign(campaign); - + console.log('Email campaign scheduled successfully:', response); } catch (error) { console.error('Error scheduling email campaign:', error); @@ -177,7 +177,7 @@ async function sendHtmlTemplateEmail() { `; - + const response = await ccai.email.sendSingle( 'John', 'Doe', @@ -189,7 +189,7 @@ async function sendHtmlTemplateEmail() { 'Your Company', 'Welcome HTML Template Email' ); - + console.log('HTML template email sent successfully:', response); } catch (error) { console.error('Error sending HTML template email:', error); diff --git a/src/examples/example.ts b/src/examples/example.ts index b8fbcbf..04bdcb1 100644 --- a/src/examples/example.ts +++ b/src/examples/example.ts @@ -15,21 +15,21 @@ dotenv.config(); // Create a new CCAI client const ccai = new CCAI({ clientId: process.env.CCAI_CLIENT_ID || '', - apiKey: process.env.CCAI_API_KEY || '' + apiKey: process.env.CCAI_API_KEY || '', }); // Example recipients const accounts: Account[] = [ { - firstName: "John", - lastName: "Doe", - phone: "+15551234567" // Use E.164 format - } + firstName: 'John', + lastName: 'Doe', + phone: '+15551234567', // Use E.164 format + }, ]; // Message with variable placeholders -const message = "Hello ${firstName} ${lastName}, this is a test message!"; -const title = "Test Campaign"; +const message = 'Hello ${firstName} ${lastName}, this is a test message!'; +const title = 'Test Campaign'; /** * Example of sending SMS messages using async/await @@ -38,22 +38,18 @@ async function sendMessages() { try { // Method 1: Send SMS to multiple recipients console.log('Sending campaign to multiple recipients...'); - const campaignResponse: SMSResponse = await ccai.sms.send( - accounts, - message, - title - ); + const campaignResponse: SMSResponse = await ccai.sms.send(accounts, message, title); console.log('SMS campaign sent successfully!'); console.log(campaignResponse); // Method 2: Send SMS to a single recipient console.log('\nSending message to a single recipient...'); const singleResponse: SMSResponse = await ccai.sms.sendSingle( - "Jane", - "Smith", - "+15559876543", - "Hi ${firstName}, thanks for your interest!", - "Single Message Test" + 'Jane', + 'Smith', + '+15559876543', + 'Hi ${firstName}, thanks for your interest!', + 'Single Message Test' ); console.log('Single SMS sent successfully!'); console.log(singleResponse); @@ -71,9 +67,9 @@ async function sendMessages() { // Execute the async function sendMessages() - .then(results => { + .then((results) => { console.log('\nAll messages sent successfully!'); - console.log('\nResults ' + results.toString()); + console.log(`\nResults ${results.toString()}`); }) .catch(() => { console.error('\nFailed to send one or more messages.'); diff --git a/src/examples/express-webhook.ts b/src/examples/express-webhook.ts index 2841b6f..9d194ea 100644 --- a/src/examples/express-webhook.ts +++ b/src/examples/express-webhook.ts @@ -1,35 +1,96 @@ import express from 'express'; +import { CCAI } from '../ccai'; import { WebhookEvent, WebhookEventType } from '../webhook/types'; +import { Webhook } from '../webhook/webhook'; const app = express(); const PORT = process.env.PORT || 3000; -// Middleware -app.use(express.json()); +// Initialize CCAI for webhook signature verification +const ccai = new CCAI({ + clientId: process.env.CCAI_CLIENT_ID || '', + apiKey: process.env.CCAI_API_KEY || '', +}); + +const webhook = new Webhook(ccai); + +// Middleware to parse JSON bodies as raw strings for signature verification +app.use(express.raw({ type: 'application/json' })); +app.use( + ( + req: express.Request & { rawBody?: Buffer }, + _res: express.Response, + next: express.NextFunction + ) => { + if (req.body && typeof req.body === 'object' && !Array.isArray(req.body)) { + next(); + } else if (req.body) { + req.rawBody = req.body as Buffer; + req.body = JSON.parse((req.body as Buffer).toString()); + next(); + } else { + next(); + } + } +); -// CCAI Webhook handler +// CCAI Webhook handler with signature verification app.post('/webhook', (req: express.Request, res: express.Response) => { const event = req.body as WebhookEvent; - + const signature = req.headers['x-ccai-signature'] as string; + const clientId = process.env.CCAI_CLIENT_ID || ''; + const webhookSecret = process.env.CCAI_WEBHOOK_SECRET || ''; + + // Verify the webhook signature + // Signature is computed as: HMAC-SHA256(webhookSecret, clientId:eventHash) in Base64 + const isValid = webhook.verifySignature(signature, clientId, event.eventHash, webhookSecret); + + if (!isValid) { + console.error('❌ Invalid webhook signature - rejecting'); + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + console.log('✅ Webhook signature verified'); console.log('CCAI Webhook Event:', event); - - switch (event.type) { + + switch (event.eventType) { case WebhookEventType.MESSAGE_SENT: - console.log(`📤 Message sent: ${event.from} → ${event.to}`); - console.log(` Campaign: ${event.campaign.title}`); - console.log(` Message: ${event.message}`); + console.log(`📤 Message sent to: ${event.data.To}`); + if (event.data.TotalPrice) { + console.log(` Cost: $${event.data.TotalPrice}`); + } + if (event.data.Segments) { + console.log(` Segments: ${event.data.Segments}`); + } break; - - case WebhookEventType.MESSAGE_RECEIVED: - console.log(`📥 Message received: ${event.from} → ${event.to}`); - console.log(` Campaign: ${event.campaign.title}`); - console.log(` Message: ${event.message}`); + + case WebhookEventType.MESSAGE_INCOMING: + console.log(`📥 Message received from: ${event.data.From}`); + console.log(` Message: ${event.data.Message}`); break; - + + case WebhookEventType.MESSAGE_EXCLUDED: + console.log(`⚠️ Message excluded: ${event.data.ExcludedReason}`); + break; + + case WebhookEventType.MESSAGE_ERROR_CARRIER: + console.log(`❌ Carrier error ${event.data.ErrorCode}: ${event.data.ErrorMessage}`); + break; + + case WebhookEventType.MESSAGE_ERROR_CLOUDCONTACT: + console.log(`🚨 System error ${event.data.ErrorCode}: ${event.data.ErrorMessage}`); + break; + default: - console.warn('Unknown event type:', (event as any).type); + console.warn('Unknown event type:', event.eventType); + } + + // Handle custom data if present + if (event.data.CustomData) { + console.log(`📌 Custom Data: ${event.data.CustomData}`); } - + res.status(200).json({ received: true }); }); @@ -41,4 +102,4 @@ app.get('/health', (_, res) => { app.listen(PORT, () => { console.log(`🚀 CCAI Webhook server running on port ${PORT}`); console.log(`📡 Webhook URL: http://localhost:${PORT}/webhook`); -}); \ No newline at end of file +}); diff --git a/src/examples/mms-example.ts b/src/examples/mms-example.ts index 95eba0e..79f61ab 100644 --- a/src/examples/mms-example.ts +++ b/src/examples/mms-example.ts @@ -1,12 +1,12 @@ /** * MMS Example - Demonstrates how to use the MMS functionality - * + * * @license MIT * @copyright 2025 CloudContactAI LLC */ import dotenv from 'dotenv'; -import { CCAI, Account, SMSOptions } from '../index'; +import { Account, CCAI, SMSOptions } from '../index'; // Load environment variables dotenv.config(); @@ -14,7 +14,7 @@ dotenv.config(); // Initialize the CCAI client const ccai = new CCAI({ clientId: process.env.CCAI_CLIENT_ID || '', - apiKey: process.env.CCAI_API_KEY || '' + apiKey: process.env.CCAI_API_KEY || '', }); // Define a progress callback @@ -26,7 +26,7 @@ const trackProgress = (status: string) => { const options: SMSOptions = { timeout: 60000, retries: 3, - onProgress: trackProgress + onProgress: trackProgress, }; /** @@ -37,18 +37,18 @@ async function sendMmsWithImage() { // Path to your image file const imagePath = 'path/to/your/image.jpg'; const contentType = 'image/jpeg'; - + // Define recipient const account: Account = { firstName: 'John', lastName: 'Doe', - phone: '+15551234567' // Use E.164 format + phone: '+15551234567', // Use E.164 format }; - + // Message content and campaign title const message = 'Hello ${firstName}, check out this image!'; const title = 'MMS Campaign Example'; - + // Send MMS with image in one step const response = await ccai.mms.sendWithImage( imagePath, @@ -56,9 +56,10 @@ async function sendMmsWithImage() { [account], message, title, + undefined, options ); - + console.log(`MMS sent! Campaign ID: ${response.campaignId}`); console.log(`Messages sent: ${response.messagesSent}`); console.log(`Status: ${response.status}`); @@ -76,57 +77,44 @@ async function sendMmsStepByStep() { const imagePath = 'path/to/your/image.jpg'; const fileName = imagePath.split('/').pop() || 'image.jpg'; const contentType = 'image/jpeg'; - + // Step 1: Get a signed URL for uploading console.log('Getting signed upload URL...'); - const uploadResponse = await ccai.mms.getSignedUploadUrl( - fileName, - contentType - ); - + const uploadResponse = await ccai.mms.getSignedUploadUrl(fileName, contentType); + const signedUrl = uploadResponse.signedS3Url; const fileKey = uploadResponse.fileKey; - + console.log(`Got signed URL: ${signedUrl}`); console.log(`File key: ${fileKey}`); - + // Step 2: Upload the image to the signed URL console.log('Uploading image...'); - const uploadSuccess = await ccai.mms.uploadImageToSignedUrl( - signedUrl, - imagePath, - contentType - ); - + const uploadSuccess = await ccai.mms.uploadImageToSignedUrl(signedUrl, imagePath, contentType); + if (!uploadSuccess) { console.error('Failed to upload image'); return; } - + console.log('Image uploaded successfully'); - + // Step 3: Send the MMS with the uploaded image console.log('Sending MMS...'); - + // Define recipients const accounts: Account[] = [ { firstName: 'John', lastName: 'Doe', phone: '+15551234567' }, - { firstName: 'Jane', lastName: 'Smith', phone: '+15559876543' } + { firstName: 'Jane', lastName: 'Smith', phone: '+15559876543' }, ]; - + // Message content and campaign title const message = 'Hello ${firstName}, check out this image!'; const title = 'MMS Campaign Example'; - + // Send the MMS - const response = await ccai.mms.send( - fileKey, - accounts, - message, - title, - options - ); - + const response = await ccai.mms.send(fileKey, accounts, message, title, undefined, options); + console.log(`MMS sent! Campaign ID: ${response.campaignId}`); console.log(`Messages sent: ${response.messagesSent}`); console.log(`Status: ${response.status}`); @@ -142,7 +130,7 @@ async function sendSingleMms() { try { // Define the file key of an already uploaded image const pictureFileKey = `${process.env.CCAI_CLIENT_ID}/campaign/your-image.jpg`; - + // Send a single MMS const response = await ccai.mms.sendSingle( pictureFileKey, @@ -151,9 +139,11 @@ async function sendSingleMms() { '+15551234567', 'Hello ${firstName}, check out this image!', 'Single MMS Example', + undefined, + undefined, options ); - + console.log(`MMS sent! Campaign ID: ${response.campaignId}`); console.log(`Status: ${response.status}`); } catch (error) { diff --git a/src/examples/webhook-example.ts b/src/examples/webhook-example.ts index 797d889..7fffd0c 100644 --- a/src/examples/webhook-example.ts +++ b/src/examples/webhook-example.ts @@ -1,72 +1,104 @@ /** - * Example of using CloudContactAI webhooks with Next.js - * - * This example shows how to create a Next.js API route that handles - * webhook events from CloudContactAI. - * + * Example of using CloudContactAI webhooks + * + * This example shows how to handle webhook events from CloudContactAI. + * * @license MIT * @copyright 2025 CloudContactAI LLC */ -import type { NextApiRequest, NextApiResponse } from 'next'; -import { createWebhookHandler, WebhookEventType } from '../index'; +import { CCAI, type WebhookEvent, WebhookEventType } from '../index'; + +// Initialize CCAI client +const ccai = new CCAI({ + clientId: process.env.CCAI_CLIENT_ID || '', + apiKey: process.env.CCAI_API_KEY || '', +}); /** - * Example Next.js API route handler for CloudContactAI webhooks - * This would typically be in a file like pages/api/ccai-webhook.ts + * Example webhook event handler */ -export default createWebhookHandler({ - // Optional: Secret for verifying webhook signatures - // secret: process.env.CCAI_WEBHOOK_SECRET, - - // Handler for outbound messages (messages sent from your campaigns) - onMessageSent: async (event) => { - console.log('Message sent event received:'); - console.log(`Campaign: ${event.campaign.title} (ID: ${event.campaign.id})`); - console.log(`From: ${event.from}`); - console.log(`To: ${event.to}`); - console.log(`Message: ${event.message}`); - - // Here you can add your custom logic for handling sent messages - // For example, updating your database, triggering other processes, etc. - }, - - // Handler for inbound messages (replies from recipients) - onMessageReceived: async (event) => { - console.log('Message received event received:'); - console.log(`Campaign: ${event.campaign.title} (ID: ${event.campaign.id})`); - console.log(`From: ${event.from}`); - console.log(`To: ${event.to}`); - console.log(`Message: ${event.message}`); - - // Here you can add your custom logic for handling received messages - // For example, updating your database, triggering automated responses, etc. - }, - - // Optional: Log events to console - logEvents: true -}); +export function handleWebhookEvent(event: WebhookEvent) { + console.log(`Webhook Event: ${event.eventType}`); + console.log(`Event Hash: ${event.eventHash}`); + + switch (event.eventType) { + case WebhookEventType.MESSAGE_SENT: + console.log('✅ Message sent successfully'); + if (event.data.To) { + console.log(` Recipient: ${event.data.To}`); + } + if (event.data.TotalPrice) { + console.log(` Cost: $${event.data.TotalPrice}`); + } + if (event.data.Segments) { + console.log(` Segments: ${event.data.Segments}`); + } + break; + + case WebhookEventType.MESSAGE_INCOMING: + console.log('📥 Message received (reply)'); + if (event.data.From) { + console.log(` From: ${event.data.From}`); + } + if (event.data.Message) { + console.log(` Message: ${event.data.Message}`); + } + break; + + case WebhookEventType.MESSAGE_EXCLUDED: + console.log('⚠️ Message excluded'); + if (event.data.ExcludedReason) { + console.log(` Reason: ${event.data.ExcludedReason}`); + } + break; + + case WebhookEventType.MESSAGE_ERROR_CARRIER: + console.log('❌ Carrier error'); + if (event.data.ErrorCode) { + console.log(` Code: ${event.data.ErrorCode}`); + } + if (event.data.ErrorMessage) { + console.log(` Message: ${event.data.ErrorMessage}`); + } + break; + + case WebhookEventType.MESSAGE_ERROR_CLOUDCONTACT: + console.log('🚨 System error'); + if (event.data.ErrorCode) { + console.log(` Code: ${event.data.ErrorCode}`); + } + if (event.data.ErrorMessage) { + console.log(` Message: ${event.data.ErrorMessage}`); + } + break; + + default: + console.log(`Unknown event type: ${event.eventType}`); + } + + // Handle custom data if present + if (event.data.CustomData) { + console.log(`📌 Custom Data: ${event.data.CustomData}`); + } +} /** - * Example of a basic Next.js API route that manually handles webhooks + * Example webhook signature verification */ -export function manualWebhookHandler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === 'POST') { - const payload = req.body; - console.log('Webhook payload:', payload); - - // Process the webhook based on its type - if (payload.type === WebhookEventType.MESSAGE_SENT) { - // Handle outbound message event - console.log('Message sent to:', payload.to); - } else if (payload.type === WebhookEventType.MESSAGE_RECEIVED) { - // Handle inbound message event - console.log('Message received from:', payload.from); - } - - // Always respond with a 200 status code to acknowledge receipt - res.status(200).json({ received: true }); +export function verifyAndHandleWebhook( + signature: string, + clientId: string, + eventHash: string, + secret: string, + payload: WebhookEvent +) { + const isValid = ccai.webhook.verifySignature(signature, clientId, eventHash, secret); + + if (isValid) { + console.log('✅ Webhook signature verified'); + handleWebhookEvent(payload); } else { - res.status(405).json({ error: 'Method not allowed' }); + console.log('❌ Invalid webhook signature - rejecting'); } } diff --git a/src/index.ts b/src/index.ts index 7fbf641..f536ec9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,33 +1,28 @@ /** * Main export file for the CCAI module - * + * * @license MIT * @copyright 2025 CloudContactAI LLC */ import { CCAI } from './ccai'; import type { Account, CCAIConfig } from './ccai'; -import { SMS } from './sms/sms'; -import type { SMSCampaign, SMSResponse, SMSOptions } from './sms/sms'; +import { Contact } from './contact/contact'; +import type { SetDoNotTextResponse } from './contact/contact'; +import { Email } from './email/email'; +import type { EmailAccount, EmailCampaign, EmailOptions, EmailResponse } from './email/email'; import { MMS } from './sms/mms'; import type { SignedUrlResponse } from './sms/mms'; -import { Email } from './email/email'; -import type { EmailCampaign, EmailResponse, EmailOptions, EmailAccount } from './email/email'; -import { Webhook } from './webhook/webhook'; +import { SMS } from './sms/sms'; +import type { SMSCampaign, SMSOptions, SMSResponse } from './sms/sms'; import { createWebhookHandler } from './webhook/nextjs'; import type { WebhookHandlerOptions } from './webhook/nextjs'; -import type { - WebhookConfig, - WebhookEvent, - WebhookEventBase, - WebhookCampaign, - MessageSentEvent, - MessageReceivedEvent -} from './webhook/types'; +import type { WebhookConfig, WebhookEvent } from './webhook/types'; import { WebhookEventType } from './webhook/types'; +import { Webhook } from './webhook/webhook'; // Re-export classes -export { CCAI, SMS, MMS, Email, Webhook, WebhookEventType, createWebhookHandler }; +export { CCAI, SMS, MMS, Email, Webhook, WebhookEventType, createWebhookHandler, Contact }; // Re-export types using 'export type' export type { @@ -43,9 +38,6 @@ export type { EmailAccount, WebhookConfig, WebhookEvent, - WebhookEventBase, - WebhookCampaign, - MessageSentEvent, - MessageReceivedEvent, - WebhookHandlerOptions + WebhookHandlerOptions, + SetDoNotTextResponse, }; diff --git a/src/mms_send.ts b/src/mms_send.ts index 585af39..b12b1f7 100644 --- a/src/mms_send.ts +++ b/src/mms_send.ts @@ -6,7 +6,7 @@ dotenv.config(); const ccai = new CCAI({ clientId: process.env.CCAI_CLIENT_ID || '', - apiKey: process.env.CCAI_API_KEY || '' + apiKey: process.env.CCAI_API_KEY || '', }); async function sendMMS() { @@ -14,19 +14,21 @@ async function sendMMS() { // Replace with actual image path - update this to point to a real image const imagePath = './image.jpg'; // Make sure image.jpg exists in the project root const contentType = 'image/jpeg'; - - const accounts = [{ - firstName: "Thavas", - lastName: "Antonio", - phone: "+15551234567" // Update with actual phone number - }]; + + const accounts = [ + { + firstName: 'Thavas', + lastName: 'Antonio', + phone: '+15551234567', // Update with actual phone number + }, + ]; const response = await ccai.mms.sendWithImage( imagePath, contentType, accounts, - "Hello ${firstName}, check out this image!", - "MMS Test" + 'Hello ${firstName}, check out this image!', + 'MMS Test' ); console.log('MMS sent successfully:', response); } catch (error) { @@ -34,4 +36,4 @@ async function sendMMS() { } } -sendMMS(); \ No newline at end of file +sendMMS(); diff --git a/src/sms/mms.ts b/src/sms/mms.ts index ccc0969..52118f9 100644 --- a/src/sms/mms.ts +++ b/src/sms/mms.ts @@ -1,34 +1,42 @@ /** * mms.ts - MMS service for the CCAI API * Handles sending MMS messages through the Cloud Contact AI platform. - * + * * @license MIT * @copyright 2025 CloudContactAI LLC */ -import axios from 'axios'; +import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; -import { CCAI, Account } from '../ccai'; +import axios from 'axios'; +import { Account, CCAI } from '../ccai'; import { SMSOptions, SMSResponse } from './sms'; +/** + * Response from the signed URL API + */ export type SignedUrlResponse = { - /** - * The signed S3 URL for uploading the file - */ + /** The signed S3 URL for uploading the file */ signedS3Url: string; - - /** - * The file key in S3 - */ + /** The file key in S3 */ fileKey: string; - - /** - * Additional data from the API - */ + /** Additional data from the API */ [key: string]: unknown; }; +/** + * Response from the stored URL check API + */ +export type StoredUrlResponse = { + /** The stored URL if the file exists, empty string otherwise */ + storedUrl: string; + [key: string]: unknown; +}; + +/** + * Service for sending multimedia messages through the CCAI API + */ export class MMS { private ccai: CCAI; @@ -43,7 +51,7 @@ export class MMS { /** * Get a signed S3 URL to upload an image file * @param fileName - Name of the file to upload - * @param fileType - MIME type of the file + * @param fileType - MIME type of the file (e.g., image/png, image/jpeg) * @param fileBasePath - Base path for the file in S3 (default: clientId/campaign) * @param publicFile - Whether the file should be public (default: true) * @returns Promise resolving to the signed URL response @@ -52,45 +60,37 @@ export class MMS { fileName: string, fileType: string, fileBasePath?: string, - publicFile: boolean = true + publicFile = true ): Promise { if (!fileName) throw new Error('File name is required'); if (!fileType) throw new Error('File type is required'); - - // Use default fileBasePath if not provided - fileBasePath = fileBasePath ?? `${this.ccai.getClientId()}/campaign`; - - // Define fileKey explicitly as clientId/campaign/filename + + const basePath = fileBasePath ?? `${this.ccai.getClientId()}/campaign`; const fileKey = `${this.ccai.getClientId()}/campaign/${fileName}`; - + const data = { fileName, fileType, - fileBasePath, - publicFile + fileBasePath: basePath, + publicFile, }; - + try { - const response = await axios.post( - 'https://files.cloudcontactai.com/upload/url', - data, - { - headers: { - 'Authorization': `Bearer ${this.ccai.getApiKey()}`, - 'Content-Type': 'application/json' - } - } - ); - - const responseData = response.data; - + const response = await axios.post(`${this.ccai.getFilesBaseUrl()}/upload/url`, data, { + headers: { + Authorization: `Bearer ${this.ccai.getApiKey()}`, + 'Content-Type': 'application/json', + }, + }); + + const responseData = response.data as SignedUrlResponse; + if (!responseData.signedS3Url) { throw new Error('Invalid response from upload URL API'); } - - // Override the fileKey with our explicitly defined one + responseData.fileKey = fileKey; - + return responseData; } catch (error) { if (error instanceof Error) { @@ -115,27 +115,20 @@ export class MMS { if (!signedUrl) throw new Error('Signed URL is required'); if (!filePath) throw new Error('File path is required'); if (!contentType) throw new Error('Content type is required'); - + try { - // Check if file exists if (!fs.existsSync(filePath)) { throw new Error(`File does not exist: ${filePath}`); } - - // Read file content + const fileContent = fs.readFileSync(filePath); - - // Upload file to S3 - const response = await axios.put( - signedUrl, - fileContent, - { - headers: { - 'Content-Type': contentType - } - } - ); - + + const response = await axios.put(signedUrl, fileContent, { + headers: { + 'Content-Type': contentType, + }, + }); + return response.status >= 200 && response.status < 300; } catch (error) { if (error instanceof Error) { @@ -149,9 +142,10 @@ export class MMS { * Send an MMS message to one or more recipients * @param pictureFileKey - S3 file key for the image * @param accounts - Array of recipient objects - * @param message - The message to send (can include ${firstName} and ${lastName} variables) + * @param message - The message to send (supports ${firstName} and ${lastName} variables) * @param title - Campaign title - * @param options - Optional settings for the MMS send operation + * @param senderPhone - Optional sender phone number + * @param options - Optional settings for timeout and progress tracking * @param forceNewCampaign - Whether to force a new campaign (default: true) * @returns Promise resolving to the API response */ @@ -160,74 +154,78 @@ export class MMS { accounts: Account[], message: string, title: string, + senderPhone?: string, options?: SMSOptions, - forceNewCampaign: boolean = true + forceNewCampaign = true ): Promise { - // Validate inputs if (!pictureFileKey) throw new Error('Picture file key is required'); if (!accounts || !Array.isArray(accounts) || accounts.length === 0) { throw new Error('At least one account is required'); } if (!message) throw new Error('Message is required'); if (!title) throw new Error('Campaign title is required'); - - // Validate each account has the required fields + accounts.forEach((account, index) => { - if (!account.firstName) throw new Error(`First name is required for account at index ${index}`); + if (!account.firstName) + throw new Error(`First name is required for account at index ${index}`); if (!account.lastName) throw new Error(`Last name is required for account at index ${index}`); if (!account.phone) throw new Error(`Phone number is required for account at index ${index}`); }); - - // Notify progress if callback provided + if (options?.onProgress) { options.onProgress('Preparing to send MMS'); } - + const endpoint = `/clients/${this.ccai.getClientId()}/campaigns/direct`; - - const campaignData = { + + // Map customData → messageData (API wire format) + const mappedAccounts = accounts.map(({ data, customData, ...rest }) => ({ + ...rest, + ...(data !== undefined ? { data } : {}), + ...(customData !== undefined ? { messageData: customData } : {}), + })); + + const campaignData: Record = { pictureFileKey, - accounts, + accounts: mappedAccounts, message, - title + title, }; - + if (senderPhone) { + campaignData.senderPhone = senderPhone; + } + try { - // Notify progress if callback provided if (options?.onProgress) { options.onProgress('Sending MMS'); } - - // Set up headers + const headers: Record = { - 'Authorization': `Bearer ${this.ccai.getApiKey()}`, - 'Content-Type': 'application/json' + Authorization: `Bearer ${this.ccai.getApiKey()}`, + 'Content-Type': 'application/json', }; - + if (forceNewCampaign) { - headers['ForceNewCampaign'] = 'true'; + headers.ForceNewCampaign = 'true'; } - - // Make the API request + const url = `${this.ccai.getBaseUrl()}${endpoint}`; - const axiosConfig: any = { headers }; + const axiosConfig: Record = { headers }; if (options?.timeout) { axiosConfig.timeout = options.timeout; } const response = await axios.post(url, campaignData, axiosConfig); - - // Notify progress if callback provided + if (options?.onProgress) { options.onProgress('MMS sent successfully'); } - + return response.data; } catch (error) { - // Notify progress if callback provided if (options?.onProgress) { options.onProgress('MMS sending failed'); } - + if (error instanceof Error) { throw new Error(`Failed to send MMS: ${error.message}`); } @@ -241,9 +239,11 @@ export class MMS { * @param firstName - Recipient's first name * @param lastName - Recipient's last name * @param phone - Recipient's phone number (E.164 format) - * @param message - The message to send (can include ${firstName} and ${lastName} variables) + * @param message - The message to send * @param title - Campaign title - * @param options - Optional settings for the MMS send operation + * @param customData - Optional arbitrary string forwarded to your webhook handler (sent as messageData) + * @param senderPhone - Optional sender phone number + * @param options - Optional settings * @param forceNewCampaign - Whether to force a new campaign (default: true) * @returns Promise resolving to the API response */ @@ -254,33 +254,39 @@ export class MMS { phone: string, message: string, title: string, + customData?: string, + senderPhone?: string, options?: SMSOptions, - forceNewCampaign: boolean = true + forceNewCampaign = true ): Promise { const account: Account = { firstName, lastName, - phone + phone, + ...(customData !== undefined ? { customData } : {}), }; - + return this.send( pictureFileKey, [account], message, title, + senderPhone, options, forceNewCampaign ); } /** - * Complete MMS workflow: get signed URL, upload image, and send MMS + * Complete MMS workflow: check cache, optionally upload, and send MMS. + * Automatically computes MD5 of the image file to avoid re-uploading identical images. * @param imagePath - Path to the image file * @param contentType - MIME type of the image * @param accounts - Array of recipient objects - * @param message - The message to send (can include ${firstName} and ${lastName} variables) + * @param message - The message to send * @param title - Campaign title - * @param options - Optional settings for the MMS send operation + * @param senderPhone - Optional sender phone number + * @param options - Optional settings * @param forceNewCampaign - Whether to force a new campaign (default: true) * @returns Promise resolving to the API response */ @@ -290,50 +296,87 @@ export class MMS { accounts: Account[], message: string, title: string, + senderPhone?: string, options?: SMSOptions, - forceNewCampaign: boolean = true + forceNewCampaign = true ): Promise { - // Create options if not provided - options = options || {}; - - // Step 1: Get the file name from the path - const fileName = path.basename(imagePath); - - // Notify progress if callback provided - if (options.onProgress) { - options.onProgress('Getting signed upload URL'); + const opts = options || {}; + + // Step 1: Compute MD5 of the image file for caching + const md5Hash = await this.md5File(imagePath); + const extension = path.extname(imagePath).replace('.', '').toLowerCase(); + const fileName = `${md5Hash}.${extension}`; + const fileKey = `${this.ccai.getClientId()}/campaign/${fileName}`; + + // Step 2: Check if the same image has already been uploaded + if (opts.onProgress) { + opts.onProgress('Checking if image already uploaded'); + } + + const storedUrlResponse = await this.checkFileUploaded(fileKey); + + if (storedUrlResponse?.storedUrl) { + // Image already uploaded, skip upload and send directly + if (opts.onProgress) { + opts.onProgress('Image already exists in S3, sending MMS'); + } + return this.send(fileKey, accounts, message, title, senderPhone, opts, forceNewCampaign); + } + + // Step 3: Get a signed URL for uploading + if (opts.onProgress) { + opts.onProgress('Getting signed upload URL'); } - - // Step 2: Get a signed URL for uploading const uploadResponse = await this.getSignedUploadUrl(fileName, contentType); const signedUrl = uploadResponse.signedS3Url; - const fileKey = uploadResponse.fileKey; - - // Notify progress if callback provided - if (options.onProgress) { - options.onProgress('Uploading image to S3'); + + // Step 4: Upload the image to the signed URL + if (opts.onProgress) { + opts.onProgress('Uploading image to S3'); } - - // Step 3: Upload the image to the signed URL const uploadSuccess = await this.uploadImageToSignedUrl(signedUrl, imagePath, contentType); - + if (!uploadSuccess) { throw new Error('Failed to upload image to S3'); } - - // Notify progress if callback provided - if (options.onProgress) { - options.onProgress('Image uploaded successfully, sending MMS'); + + // Step 5: Send the MMS with the uploaded image + if (opts.onProgress) { + opts.onProgress('Image uploaded successfully, sending MMS'); + } + return this.send(fileKey, accounts, message, title, senderPhone, opts, forceNewCampaign); + } + + /** + * Check if a file has already been uploaded to S3 + * @param fileKey - The S3 file key to check + * @returns Promise resolving to the stored URL response, or empty storedUrl on error + */ + async checkFileUploaded(fileKey: string): Promise { + try { + const response = await this.ccai.request( + 'GET', + `/clients/${this.ccai.getClientId()}/storedUrl?fileKey=${fileKey}` + ); + return response; + } catch { + return { storedUrl: '' } as StoredUrlResponse; } - - // Step 4: Send the MMS with the uploaded image - return this.send( - fileKey, - accounts, - message, - title, - options, - forceNewCampaign - ); + } + + /** + * Calculate the MD5 hash of a file + * @param filePath - Path to the file + * @returns Promise resolving to the MD5 hash in hexadecimal format + */ + private async md5File(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('md5'); + const stream = fs.createReadStream(filePath); + + stream.on('data', (data) => hash.update(data)); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); } } diff --git a/src/sms/sms.ts b/src/sms/sms.ts index bd5b130..5d1b2d6 100644 --- a/src/sms/sms.ts +++ b/src/sms/sms.ts @@ -1,17 +1,18 @@ /** * sms.ts - SMS service for the CCAI API * Handles sending SMS messages through the Cloud Contact AI platform. - * + * * @license MIT * @copyright 2025 CloudContactAI LLC */ -import { CCAI, Account } from '../ccai'; +import { Account, CCAI } from '../ccai'; export type SMSCampaign = { accounts: Account[]; message: string; title: string; + senderPhone?: string; }; export type SMSResponse = { @@ -19,6 +20,8 @@ export type SMSResponse = { id?: string; status?: string; campaignId?: string; + message?: string; + responseId?: string; messagesSent?: number; timestamp?: string; [key: string]: unknown; @@ -29,12 +32,12 @@ export type SMSOptions = { * Optional timeout in milliseconds */ timeout?: number; - + /** * Optional retry count for failed requests */ retries?: number; - + /** * Optional callback for tracking progress */ @@ -61,60 +64,75 @@ export class SMS { * @returns Promise resolving to the API response */ async send( - accounts: Account[], - message: string, + accounts: Account[], + message: string, title: string, + senderPhone?: string, options?: SMSOptions ): Promise { // Validate inputs if (!accounts || !Array.isArray(accounts) || accounts.length === 0) { throw new Error('At least one account is required'); } - + if (!message) throw new Error('Message is required'); if (!title) throw new Error('Campaign title is required'); - + // Validate each account has the required fields accounts.forEach((account, index) => { - if (!account.firstName) throw new Error(`First name is required for account at index ${index}`); + if (!account.firstName) + throw new Error(`First name is required for account at index ${index}`); if (!account.lastName) throw new Error(`Last name is required for account at index ${index}`); if (!account.phone) throw new Error(`Phone number is required for account at index ${index}`); }); - + // Notify progress if callback provided if (options?.onProgress) { options.onProgress('Preparing to send SMS'); } - + const endpoint = `/clients/${this.ccai.getClientId()}/campaigns/direct`; - + + // Map user-facing fields to API wire format: + // data → sent as "data" (variable substitution in message) + // customData → sent as "messageData" (forwarded as-is to webhook) + const mappedAccounts = accounts.map(({ data, customData, ...rest }) => ({ + ...rest, + ...(data !== undefined ? { data } : {}), + ...(customData !== undefined ? { messageData: customData } : {}), + })); + const campaignData: SMSCampaign = { - accounts, + accounts: mappedAccounts as Account[], message, - title + title, }; - + + if (senderPhone) { + campaignData.senderPhone = senderPhone; + } + try { // Notify progress if callback provided if (options?.onProgress) { options.onProgress('Sending SMS'); } - + // Make the API request const response = await this.ccai.request('post', endpoint, campaignData); - + // Notify progress if callback provided if (options?.onProgress) { options.onProgress('SMS sent successfully'); } - + return response; } catch (error) { // Notify progress if callback provided if (options?.onProgress) { options.onProgress('SMS sending failed'); } - + throw error; } } @@ -126,6 +144,8 @@ export class SMS { * @param phone - Recipient's phone number (E.164 format) * @param message - The message to send (can include ${firstName} and ${lastName} variables) * @param title - Campaign title + * @param customData - Optional arbitrary string forwarded to your webhook handler (sent as messageData) + * @param senderPhone - Optional sender phone number * @param options - Optional settings for the SMS send operation * @returns Promise resolving to the API response */ @@ -135,14 +155,17 @@ export class SMS { phone: string, message: string, title: string, + customData?: string, + senderPhone?: string, options?: SMSOptions ): Promise { const account: Account = { firstName, lastName, - phone + phone, + ...(customData !== undefined ? { customData } : {}), }; - - return this.send([account], message, title, options); + + return this.send([account], message, title, senderPhone, options); } } diff --git a/src/sms_send.ts b/src/sms_send.ts index 670112b..60b5ccd 100644 --- a/src/sms_send.ts +++ b/src/sms_send.ts @@ -9,11 +9,11 @@ const ccai = new CCAI({ async function sendSMS() { try { const response = await ccai.sms.sendSingle( - "John", - "Doe", -"+15551234567", - "Hello ${firstName}, this is a test SMS!", - "SMS Test" + 'John', + 'Doe', + '+15551234567', + 'Hello ${firstName}, this is a test SMS!', + 'SMS Test' ); console.log('SMS sent successfully:', response); } catch (error) { @@ -21,4 +21,4 @@ async function sendSMS() { } } -sendSMS(); \ No newline at end of file +sendSMS(); diff --git a/src/webhook/nextjs.ts b/src/webhook/nextjs.ts index a5938aa..33d5949 100644 --- a/src/webhook/nextjs.ts +++ b/src/webhook/nextjs.ts @@ -1,6 +1,6 @@ /** * nextjs.ts - Utilities for handling CloudContactAI webhooks in Next.js applications - * + * * @license MIT * @copyright 2025 CloudContactAI LLC */ @@ -13,17 +13,17 @@ export interface WebhookHandlerOptions { * Secret used to verify webhook signatures */ secret?: string; - + /** * Handler for Message Sent events */ onMessageSent?: (event: WebhookEvent) => Promise | void; - + /** * Handler for Message Received events */ onMessageReceived?: (event: WebhookEvent) => Promise | void; - + /** * Whether to log events to console */ @@ -32,16 +32,22 @@ export interface WebhookHandlerOptions { /** * Create a Next.js API route handler for CloudContactAI webhooks - * + * * @param options - Configuration options for the webhook handler * @returns Next.js API route handler function - * + * * @example * ```typescript * // pages/api/ccai-webhook.ts * import type { NextApiRequest, NextApiResponse } from 'next'; * import { createWebhookHandler } from 'ccai-node'; - * + * import { CCAI } from 'ccai-node'; + * + * const ccai = new CCAI({ + * clientId: process.env.CCAI_CLIENT_ID || '', + * apiKey: process.env.CCAI_API_KEY || '' + * }); + * * export default createWebhookHandler({ * secret: process.env.CCAI_WEBHOOK_SECRET, * onMessageSent: async (event) => { @@ -65,43 +71,31 @@ export function createWebhookHandler(options: WebhookHandlerOptions = {}) { try { // Get the request body const body = req.body as WebhookEvent; - - // Verify signature if secret is provided - if (options.secret) { - const signature = req.headers['x-ccai-signature'] as string; - - if (!signature) { - return res.status(400).json({ error: 'Missing signature header' }); - } - - // Signature verification would go here - // This is a placeholder for actual verification logic - } - + // Log the event if enabled if (options.logEvents) { console.log('CloudContactAI webhook event:', body); } - + // Handle the event based on type - switch (body.type) { + switch (body.eventType) { case WebhookEventType.MESSAGE_SENT: if (options.onMessageSent) { await options.onMessageSent(body); } break; - + case WebhookEventType.MESSAGE_RECEIVED: if (options.onMessageReceived) { await options.onMessageReceived(body); } break; - + default: // Unknown event type - console.warn('Unknown webhook event type:', (body as any).type); + console.warn('Unknown webhook event type:', body.eventType); } - + // Return success return res.status(200).json({ received: true }); } catch (error) { diff --git a/src/webhook/types.ts b/src/webhook/types.ts index 4e8860a..1c108a9 100644 --- a/src/webhook/types.ts +++ b/src/webhook/types.ts @@ -1,6 +1,6 @@ /** * Types for CloudContactAI webhook events - * + * * @license MIT * @copyright 2025 CloudContactAI LLC */ @@ -21,10 +21,9 @@ export interface WebhookCampaign { * Base interface for all webhook events */ export interface WebhookEventBase { - campaign: WebhookCampaign; - from: string; - to: string; - message: string; + eventType: string; + data: Record; + eventHash: string; // Hash computed by the backend, used for signature verification } /** @@ -32,33 +31,30 @@ export interface WebhookEventBase { */ export enum WebhookEventType { MESSAGE_SENT = 'message.sent', - MESSAGE_RECEIVED = 'message.received' -} - -/** - * Message Sent (Outbound) webhook event - */ -export interface MessageSentEvent extends WebhookEventBase { - type: WebhookEventType.MESSAGE_SENT; + MESSAGE_RECEIVED = 'message.received', + MESSAGE_INCOMING = 'message.incoming', + MESSAGE_EXCLUDED = 'message.excluded', + MESSAGE_ERROR_CARRIER = 'message.error.carrier', + MESSAGE_ERROR_CLOUDCONTACT = 'message.error.cloudcontact', } /** - * Message Received (Inbound) webhook event + * Generic webhook event structure */ -export interface MessageReceivedEvent extends WebhookEventBase { - type: WebhookEventType.MESSAGE_RECEIVED; +export interface WebhookEvent extends WebhookEventBase { + eventType: string; + data: Record; + eventHash: string; } -/** - * Union type for all webhook events - */ -export type WebhookEvent = MessageSentEvent | MessageReceivedEvent; - /** * Configuration for webhook integration */ export interface WebhookConfig { url: string; - events: WebhookEventType[]; + events?: WebhookEventType[]; secret?: string; // Optional secret for webhook signature verification + secretKey?: string; // Alternative key name + method?: string; + integrationType?: string; } diff --git a/src/webhook/webhook.ts b/src/webhook/webhook.ts index d137f76..a775610 100644 --- a/src/webhook/webhook.ts +++ b/src/webhook/webhook.ts @@ -1,14 +1,18 @@ /** * webhook.ts - A TypeScript module for managing CloudContactAI webhooks * This module provides functionality to configure and manage webhooks for CCAI events. - * + * * @license MIT * @copyright 2025 CloudContactAI LLC */ +import { createHmac, timingSafeEqual } from 'crypto'; import { CCAI } from '../ccai'; -import { WebhookConfig, WebhookEventType } from './types'; +import { WebhookConfig, WebhookEvent } from './types'; +/** + * Service for managing CloudContactAI webhook endpoints + */ export class Webhook { private ccai: CCAI; @@ -22,11 +26,50 @@ export class Webhook { /** * Register a new webhook endpoint - * @param config - Webhook configuration - * @returns Promise resolving to the registered webhook details + * @param config - Webhook configuration (url required, secret is optional) + * @returns Promise resolving to the registered webhook details (including secretKey) */ - async register(config: WebhookConfig): Promise<{ id: string; url: string; events: WebhookEventType[] }> { - return this.ccai.request('POST', '/webhooks', config); + async register(config: WebhookConfig): Promise<{ + id: string; + url: string; + method: string; + integrationType: string; + secretKey?: string; + }> { + const secret = config.secretKey || config.secret; + + // Build payload with optional secretKey + // If secretKey is not provided, the server will generate one automatically + const webhookPayload: Record = { + url: config.url, + method: 'POST', + integrationType: config.integrationType || 'ALL', + }; + + // Only include secretKey if explicitly provided + if (secret) { + webhookPayload.secretKey = secret; + } + + const payload = [webhookPayload]; + + const result = await this.ccai.request( + 'POST', + `/v1/client/${this.ccai.getClientId()}/integration`, + payload + ); + + // API returns an array — return the first element + if (Array.isArray(result) && result[0]) { + return result[0]; + } + return result as { + id: string; + url: string; + method: string; + integrationType: string; + secretKey?: string; + }; } /** @@ -35,41 +78,115 @@ export class Webhook { * @param config - Updated webhook configuration * @returns Promise resolving to the updated webhook details */ - async update(id: string, config: Partial): Promise<{ id: string; url: string; events: WebhookEventType[] }> { - return this.ccai.request('PUT', `/webhooks/${id}`, config); + async update( + id: string, + config: Partial + ): Promise<{ + id: string; + url: string; + method: string; + integrationType: string; + secretKey?: string; + }> { + const secret = config.secretKey || config.secret; + + // Build payload with optional secretKey + const webhookPayload: Record = { + id: parseInt(id, 10), + url: config.url || '', + method: config.method || 'POST', + integrationType: config.integrationType || 'ALL', + }; + + // Only include secretKey if explicitly provided + if (secret) { + webhookPayload.secretKey = secret; + } + + const payload = [webhookPayload]; + + const result = await this.ccai.request( + 'POST', + `/v1/client/${this.ccai.getClientId()}/integration`, + payload + ); + + // API returns an array — return the first element + if (Array.isArray(result) && result[0]) { + return result[0]; + } + return result as { + id: string; + url: string; + method: string; + integrationType: string; + secretKey?: string; + }; } /** * List all registered webhooks * @returns Promise resolving to an array of webhook configurations */ - async list(): Promise> { - return this.ccai.request('GET', '/webhooks'); + async list(): Promise< + Array<{ id: string; url: string; method: string; integrationType: string }> + > { + return this.ccai.request('GET', `/v1/client/${this.ccai.getClientId()}/integration`); } /** * Delete a webhook * @param id - Webhook ID - * @returns Promise resolving to a success message + * @returns Promise resolving to a success response */ async delete(id: string): Promise<{ success: boolean; message: string }> { - return this.ccai.request('DELETE', `/webhooks/${id}`); + return this.ccai.request('DELETE', `/v1/client/${this.ccai.getClientId()}/integration/${id}`); + } + + /** + * Verify a webhook signature using HMAC-SHA256 + * Signature is computed as: HMAC-SHA256(secretKey, clientId:eventHash) encoded in Base64 + * @param signature - Signature from the X-CCAI-Signature header (Base64 encoded) + * @param clientId - Client ID + * @param eventHash - Event hash from the webhook payload + * @param secret - Webhook secret key + * @returns True if the signature is valid + */ + verifySignature( + signature: string, + clientId: string | number, + eventHash: string, + secret: string + ): boolean { + if (!signature || !clientId || !eventHash || !secret) { + return false; + } + + try { + // Compute: HMAC-SHA256(secretKey, "$clientId:$eventHash") + const data = `${clientId}:${eventHash}`; + const computed = createHmac('sha256', secret).update(data).digest('base64'); + + // Constant-time comparison to prevent timing attacks + return timingSafeEqual(Buffer.from(signature), Buffer.from(computed)); + } catch { + return false; + } } /** - * Verify a webhook signature - * @param signature - Signature from the X-CCAI-Signature header - * @param body - Raw request body - * @param secret - Webhook secret - * @returns boolean indicating if the signature is valid + * Parse a raw webhook JSON payload into a structured event + * @param payload - Raw JSON payload string + * @returns Parsed webhook event object + * @throws Error if the payload is invalid JSON */ - verifySignature(_signature: string, _body: string, _secret: string): boolean { - // This is a placeholder for signature verification logic - // In a real implementation, this would use crypto to verify HMAC signatures - // Example implementation would be similar to how Stripe or GitHub verify webhook signatures - - // For now, we'll return true as this is just a placeholder - // In production, this should be implemented with proper cryptographic verification - return true; + parseEvent(payload: string): WebhookEvent { + try { + return JSON.parse(payload) as WebhookEvent; + } catch (error) { + throw new Error( + `Invalid JSON payload: ${error instanceof Error ? error.message : 'unknown error'}` + ); + } } } diff --git a/tsconfig.json b/tsconfig.json index 363d30f..e4091ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,9 +26,9 @@ "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - "allowUnreachableCode": false + "allowUnreachableCode": false, + "types": ["jest", "node"] }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"], - "types": ["jest", "node"] + "exclude": ["node_modules", "dist"] }