diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..88c1eb1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +node_modules/ +coverage/ +dist/ +examples/ +*.md +LICENSE +.env +.env.* +*.log +*.jpg +*.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7c12a6..ef81d4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,9 +39,3 @@ jobs: cache: 'npm' - run: npm ci - run: npm run test:coverage - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - token: ${{ secrets.CODECOV_TOKEN }} - directory: ./coverage/ - fail_ci_if_error: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..94c1460 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,30 @@ +name: Publish to NPM + +on: + push: + tags: + - 'release/v*' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + cache: 'npm' + + - run: npm ci + - run: npm run build + - run: npm test + + - name: Publish to NPM + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 4e836c1..33353b8 100644 --- a/.gitignore +++ b/.gitignore @@ -86,10 +86,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 + +.claude/ diff --git a/README.md b/README.md index e2bde1b..647821d 100644 --- a/README.md +++ b/README.md @@ -71,51 +71,200 @@ const ccai = new CCAI({ apiKey: 'API-KEY-TOKEN' }); -// Define a progress callback -const trackProgress = (status: string) => { - console.log(`Progress: ${status}`); -}; +// ── Option A: All-in-one (recommended) ───────────────────────────────────── +// sendWithImage handles upload + send in one call +const account: Account = { firstName: 'John', lastName: 'Doe', phone: '+15551234567' }; + +const response = await ccai.mms.sendWithImage( + 'path/to/image.jpg', // local image path + 'image/jpeg', // content type + [account], // recipients + 'Hello ${firstName}, check out this image!', + 'MMS Campaign', + // optional: senderPhone?: string + // optional: options?: SMSOptions + // optional: forceNewCampaign?: boolean (default true) +); +console.log(`MMS sent! Campaign ID: ${response.campaignId}`); + +// ── Option B: Manual workflow (step-by-step) ──────────────────────────────── + +// Step 1 — Get a pre-signed S3 upload URL +const { signedS3Url, fileKey } = await ccai.mms.getSignedUploadUrl( + 'image.jpg', // fileName + 'image/jpeg' // fileType + // optional: fileBasePath?: string + // optional: publicFile?: boolean +); + +// Step 2 — Upload the image directly to S3 +const uploaded = await ccai.mms.uploadImageToSignedUrl( + signedS3Url, + 'path/to/image.jpg', + 'image/jpeg' +); + +// Step 3 — (Optional) Confirm file is available +const stored = await ccai.mms.checkFileUploaded(fileKey); +console.log('File URL:', stored?.url); + +// Step 4a — Send to multiple recipients using the uploaded fileKey +const bulkResponse = await ccai.mms.send( + fileKey, + [account], + 'Hello ${firstName}!', + 'MMS Campaign' + // optional: senderPhone?: string + // optional: options?: SMSOptions + // optional: forceNewCampaign?: boolean +); + +// Step 4b — Send to a single recipient +const singleResponse = await ccai.mms.sendSingle( + fileKey, + 'John', + 'Doe', + '+15551234567', + 'Hello ${firstName}!', + 'MMS Campaign' + // optional: customData?: string + // optional: senderPhone?: string + // optional: options?: SMSOptions + // optional: forceNewCampaign?: boolean +); -// Create options with progress tracking +// ── Progress tracking ──────────────────────────────────────────────────────── const options: SMSOptions = { timeout: 60000, - onProgress: trackProgress + retries: 3, + onProgress: (status: string) => console.log(`Progress: ${status}`) }; +``` -// Complete MMS workflow (get URL, upload image, send MMS) -async function sendMmsWithImage() { - try { - // 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' - }; - - // Send MMS with image in one step - const response = await ccai.mms.sendWithImage( - imagePath, - contentType, - [account], - "Hello ${firstName}, check out this image!", - "MMS Campaign Example", - options - ); - - console.log(`MMS sent! Campaign ID: ${response.campaignId}`); - } catch (error) { - console.error('Error sending MMS:', error); - } -} +### Brands + +Register and manage brands for TCR (The Campaign Registry) business verification. + +```typescript +import { CCAI } from 'ccai-node'; + +const ccai = new CCAI({ + clientId: 'YOUR-CLIENT-ID', + apiKey: 'API-KEY-TOKEN' +}); + +// Create a brand +const brand = await ccai.brands.create({ + legalCompanyName: 'Collect.org Inc.', + dba: 'Collect', + entityType: 'NON_PROFIT', + taxId: '123456789', + taxIdCountry: 'US', + country: 'US', + verticalType: 'NON_PROFIT', + websiteUrl: 'https://www.collect.org', + street: '123 Main Street', + city: 'San Francisco', + state: 'CA', + postalCode: '94105', + contactFirstName: 'Jane', + contactLastName: 'Doe', + contactEmail: 'jane@collect.org', + contactPhone: '+14155551234' +}); +console.log('Brand created:', brand.id); + +// Get a brand by ID +const fetched = await ccai.brands.get(brand.id); +console.log('Website match score:', fetched.websiteMatchScore); + +// List all brands for the account +const brands = await ccai.brands.list(); +console.log(`Found ${brands.length} brand(s)`); + +// Update a brand (partial update) +const updated = await ccai.brands.update(brand.id, { + street: '456 Oak Avenue', + city: 'Los Angeles' +}); + +// Delete a brand +await ccai.brands.delete(brand.id); +``` + +#### Entity Types + +`PRIVATE_PROFIT`, `PUBLIC_PROFIT`, `NON_PROFIT`, `GOVERNMENT`, `SOLE_PROPRIETOR` + +> Note: `PUBLIC_PROFIT` entities require `stockSymbol` and `stockExchange` fields. + +#### Vertical Types + +`AUTOMOTIVE`, `AGRICULTURE`, `BANKING`, `COMMUNICATION`, `CONSTRUCTION`, `EDUCATION`, `ENERGY`, `ENTERTAINMENT`, `GOVERNMENT`, `HEALTHCARE`, `HOSPITALITY`, `INSURANCE`, `LEGAL`, `MANUFACTURING`, `NON_PROFIT`, `PROFESSIONAL`, `REAL_ESTATE`, `RETAIL`, `TECHNOLOGY`, `TRANSPORTATION` + +### Campaigns + +Register and manage campaigns for TCR (The Campaign Registry) carrier vetting. Each campaign must be linked to a verified brand. + +```typescript +import { CCAI } from 'ccai-node'; + +const ccai = new CCAI({ + clientId: 'YOUR-CLIENT-ID', + apiKey: 'API-KEY-TOKEN' +}); + +// Create a campaign +const campaign = await ccai.campaigns.create({ + brandId: 1, + useCase: 'MIXED', + subUseCases: ['CUSTOMER_CARE', 'TWO_FACTOR_AUTHENTICATION', 'ACCOUNT_NOTIFICATION'], + description: 'Security codes and support messaging.', + messageFlow: 'Users opt-in via signup form at https://example.com/signup', + hasEmbeddedLinks: true, + hasEmbeddedPhone: false, + isAgeGated: false, + isDirectLending: false, + optInKeywords: ['START'], + optInMessage: 'Welcome! Reply STOP to cancel.', + optInProofUrl: 'https://example.com/opt-in-proof.png', + helpKeywords: ['HELP'], + helpMessage: 'For HELP email support@example.com.', + optOutKeywords: ['STOP'], + optOutMessage: 'STOP received. You are unsubscribed.', + sampleMessages: [ + 'Your code is 554321. Reply STOP to cancel.', + 'Your ticket has been updated. Reply HELP for info.' + ] +}); +console.log('Campaign created:', campaign.id); + +// Get a campaign by ID +const fetchedCampaign = await ccai.campaigns.get(campaign.id); -// Call the function -sendMmsWithImage(); +// List all campaigns for the account +const campaigns = await ccai.campaigns.list(); +console.log(`Found ${campaigns.length} campaign(s)`); + +// Update a campaign (partial update) +const updatedCampaign = await ccai.campaigns.update(campaign.id, { + description: 'Updated description.' +}); + +// Delete a campaign +await ccai.campaigns.delete(campaign.id); ``` +#### Use Cases + +`TWO_FACTOR_AUTHENTICATION`, `ACCOUNT_NOTIFICATION`, `CUSTOMER_CARE`, `DELIVERY_NOTIFICATION`, `FRAUD_ALERT`, `HIGHER_EDUCATION`, `LOW_VOLUME_MIXED`, `MARKETING`, `MIXED`, `POLLING_VOTING`, `PUBLIC_SERVICE_ANNOUNCEMENT`, `SECURITY_ALERT` + +> Note: `MIXED` and `LOW_VOLUME_MIXED` campaigns require 2–3 `subUseCases`. + +#### Sub-Use Cases + +`TWO_FACTOR_AUTHENTICATION`, `ACCOUNT_NOTIFICATION`, `CUSTOMER_CARE`, `DELIVERY_NOTIFICATION`, `FRAUD_ALERT`, `MARKETING`, `POLLING_VOTING` + ### Email ```typescript @@ -132,11 +281,12 @@ const response = await ccai.email.sendSingle( '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' + 'Hello ${firstName},
Thank you for signing up!
', // htmlContent (message) + undefined, // textContent: plain-text alternative (optional) + 'noreply@yourcompany.com', // senderEmail (optional, defaults to noreply@cloudcontactai.com) + 'support@yourcompany.com', // replyEmail (optional) + 'Your Company', // senderName (optional) + 'Welcome Email' // title (optional) ); console.log('Email sent:', response); @@ -181,6 +331,43 @@ await ccai.contact.setDoNotText(false, undefined, '+15551234567'); await ccai.contact.setDoNotText(true, 'contact-abc-123'); ``` +### Contact Validator + +Validate email addresses and phone numbers. + +```typescript +import { CCAI } from 'ccai-node'; + +const ccai = new CCAI({ + clientId: 'YOUR-CLIENT-ID', + apiKey: 'YOUR-API-KEY' +}); + +// Validate a single email +const emailResult = await ccai.contactValidator.validateEmail('user@example.com'); +console.log(emailResult.status); // "valid" | "invalid" | "risky" +console.log(emailResult.metadata.safe_to_send); // true | false + +// Validate multiple emails (up to 50) +const bulkEmails = await ccai.contactValidator.validateEmails([ + 'user@example.com', + 'invalid@nonexistent.xyz' +]); +console.log(bulkEmails.summary); // { total: 2, valid: 1, invalid: 1, risky: 0 } + +// Validate a single phone number +const phoneResult = await ccai.contactValidator.validatePhone('+15551234567', 'US'); +console.log(phoneResult.status); // "valid" | "invalid" | "landline" +console.log(phoneResult.metadata.carrier_type); // "mobile" | "landline" | "voip" + +// Validate multiple phone numbers (up to 50) +const bulkPhones = await ccai.contactValidator.validatePhones([ + { phone: '+15551234567' }, + { phone: '+15559876543', countryCode: 'US' } +]); +console.log(bulkPhones.summary); // { total: 2, valid: 1, invalid: 0, risky: 0, landline: 1 } +``` + ### Webhooks 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. @@ -199,6 +386,9 @@ const ccai = new CCAI({ const webhookConfig: WebhookConfig = { url: 'https://your-app.com/api/ccai-webhook', // secret is optional - if not provided, server generates one automatically + // method?: string (default 'POST') + // integrationType?: string (e.g. 'REST') + // events?: WebhookEventType[] }; const webhook = await ccai.webhook.register(webhookConfig); console.log('Webhook registered with ID:', webhook.id); @@ -252,10 +442,16 @@ console.log('Message:', event.data.Message); #### Webhook Events -CloudContactAI currently supports the following webhook events: +CloudContactAI supports the following webhook event types (available via `WebhookEventType` enum): -1. **Message Sent (Outbound)** - Triggered when a message is sent from your CloudContactAI account -2. **Message Received (Inbound)** - Triggered when a message is received by your CloudContactAI account +| Event | Value | Description | +|---|---|---| +| `MESSAGE_SENT` | `message.sent` | Outbound message sent from your account | +| `MESSAGE_RECEIVED` | `message.received` | Inbound message received by your account | +| `MESSAGE_INCOMING` | `message.incoming` | Incoming message before processing | +| `MESSAGE_EXCLUDED` | `message.excluded` | Message excluded (e.g. opted-out contact) | +| `MESSAGE_ERROR_CARRIER` | `message.error.carrier` | Carrier-side delivery error | +| `MESSAGE_ERROR_CLOUDCONTACT` | `message.error.cloudcontact` | Platform-side delivery error | #### Event Payload Schema @@ -542,7 +738,10 @@ This project includes a `.gitignore` file that excludes: - 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 +- Brand registration and management for TCR verification +- Campaign registration and management for TCR carrier vetting - Manage contact opt-out preferences (setDoNotText) +- Validate email addresses (valid/invalid/risky) and phone numbers (valid/invalid/landline) - Webhook management: register, list, update, delete - Webhook signature verification (HMAC-SHA256) - Next.js API route handlers for webhook events diff --git a/integration/Dockerfile b/integration/Dockerfile new file mode 100644 index 0000000..b9d0786 --- /dev/null +++ b/integration/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20-alpine + +WORKDIR /sdk +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +COPY tsconfig.json ./ +COPY src/ ./src/ +RUN npm run build + +WORKDIR /sdk/integration +COPY integration/package.json ./ +RUN npm install + +COPY integration/ ./ + +CMD ["npx", "ts-node", "--project", "tsconfig.json", "test.ts"] diff --git a/integration/Dockerfile.release b/integration/Dockerfile.release new file mode 100644 index 0000000..3c6d377 --- /dev/null +++ b/integration/Dockerfile.release @@ -0,0 +1,20 @@ +FROM node:20-alpine + +ARG SDK_VERSION=1.0.2 + +# Install the published SDK from npm into /sdk +WORKDIR /sdk +RUN npm install ccai-node@${SDK_VERSION} + +# test.ts uses `require('../dist/index.js')` — symlink so that path resolves +# to the installed package's compiled output +RUN ln -s /sdk/node_modules/ccai-node/dist /sdk/dist + +# Set up the integration test runner (ts-node, typescript, etc.) +WORKDIR /sdk/integration +COPY integration/package.json ./ +RUN npm install + +COPY integration/ ./ + +CMD ["npx", "ts-node", "--project", "tsconfig.json", "test.ts"] diff --git a/integration/package.json b/integration/package.json new file mode 100644 index 0000000..3a63e7e --- /dev/null +++ b/integration/package.json @@ -0,0 +1,16 @@ +{ + "name": "ccai-test-node", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "ts-node --project tsconfig.json test.ts" + }, + "dependencies": { + "axios": "^1.6.7" + }, + "devDependencies": { + "@types/node": "^20.11.24", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/integration/test.ts b/integration/test.ts new file mode 100644 index 0000000..7036cba --- /dev/null +++ b/integration/test.ts @@ -0,0 +1,634 @@ +/** + * Node.js SDK integration tests — 42 tests + * Covers: SMS (1-6), MMS (7-17), Email (18-22), Webhook (23-29), Contact (30-31), Brands (32-36), Campaigns (37-42) + */ + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// Import from the locally built SDK +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { CCAI } = require('../dist/index.js'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +let passed = 0; +let failed = 0; + +async function run(name: string, fn: () => PromiseHello from Node SDK!
', + undefined, + senderEmail, replyEmail, senderName, + 'Node Email Test' + ); + }); + + // 19 — Email.send (1 recipient) + await run('19 Email.send (1 recipient)', async () => { + await client.email.send( + [{ firstName: firstName1, lastName: lastName1, phone: phone1, email: email1 }], + 'Node SDK Email 1', + 'Hello 1!
', + senderEmail, replyEmail, senderName, + 'Node Email Test' + ); + }); + + // 20 — Email.send (2 recipients) + await run('20 Email.send (2 recipients)', async () => { + await client.email.send( + [ + { firstName: firstName1, lastName: lastName1, phone: phone1, email: email1 }, + { firstName: firstName2, lastName: lastName2, phone: phone2, email: email2 }, + ], + 'Node SDK Email 2', + 'Hello 2!
', + senderEmail, replyEmail, senderName, + 'Node Email Test' + ); + }); + + // 21 — Email.send (3 recipients) + await run('21 Email.send (3 recipients)', async () => { + await client.email.send( + [ + { firstName: firstName1, lastName: lastName1, phone: phone1, email: email1 }, + { firstName: firstName2, lastName: lastName2, phone: phone2, email: email2 }, + { firstName: firstName3, lastName: lastName3, phone: phone3, email: email3 }, + ], + 'Node SDK Email 3', + 'Hello 3!
', + senderEmail, replyEmail, senderName, + 'Node Email Test' + ); + }); + + // 22 — Email.sendCampaign (direct campaign object) + await run('22 Email.sendCampaign', async () => { + const campaign = { + subject: 'Node SDK Campaign Test', + title: 'Node Email Campaign', + message: 'Campaign email from Node SDK!
', + senderEmail, + replyEmail, + senderName, + accounts: [ + { firstName: firstName1, lastName: lastName1, phone: phone1, email: email1 }, + { firstName: firstName2, lastName: lastName2, phone: phone2, email: email2 }, + ], + campaignType: 'EMAIL' as const, + addToList: 'noList', + contactInput: 'accounts', + fromType: 'single', + senders: [], + }; + await client.email.sendCampaign(campaign); + }); + + // ── Webhook Tests (23-29) ──────────────────────────────────────────────────── + console.log('\n--- Webhook ---'); + + const secret = 'test-webhook-secret-node'; + let registeredWebhookId = ''; + + // 23 — Webhook.register + await run('23 Webhook.register', async () => { + const resp = await client.webhook.register({ url: webhookURL, secret }); + const id = resp?.id; + if (!id) throw new Error('webhook ID is empty after register'); + registeredWebhookId = String(id); + }); + + // 24 — Webhook.list + await run('24 Webhook.list', async () => { + const hooks = await client.webhook.list(); + if (!Array.isArray(hooks) || hooks.length === 0) + throw new Error('expected at least one webhook, got 0'); + }); + + // 25 — Webhook.update + await run('25 Webhook.update', async () => { + if (!registeredWebhookId) throw new Error('no webhook ID from test 23'); + await client.webhook.update(registeredWebhookId, { + url: webhookURL + '?updated=1', + secret: 'updated-secret-node', + }); + }); + + // 26 — Webhook.verifySignature (valid) + await run('26 Webhook.verifySignature (valid)', async () => { + const eventHash = 'abc123eventHash'; + const sig = hmacSHA256Base64(secret, `${clientId}:${eventHash}`); + const ok = client.webhook.verifySignature(sig, clientId, eventHash, secret); + if (!ok) throw new Error('expected valid signature to return true'); + }); + + // 27 — Webhook.verifySignature (invalid) + await run('27 Webhook.verifySignature (invalid)', async () => { + const ok = client.webhook.verifySignature('invalidsig==', clientId, 'somehash', secret); + if (ok) throw new Error('expected invalid signature to return false'); + }); + + // 28 — Webhook.parseEvent + await run('28 Webhook.parseEvent', async () => { + const payload = JSON.stringify({ + eventType: 'message.sent', + data: { to: '+15005550001' }, + eventHash: 'abc123', + }); + const event = client.webhook.parseEvent(payload); + if (!event.eventType) throw new Error('eventType is empty after parseEvent'); + }); + + // 29 — Webhook.delete + await run('29 Webhook.delete', async () => { + if (!registeredWebhookId) throw new Error('no webhook ID from test 23'); + await client.webhook.delete(registeredWebhookId); + }); + + // ── Contact Tests (30-31) ──────────────────────────────────────────────────── + console.log('\n--- Contact ---'); + + // 30 — Contact.setDoNotText(true) + await run('30 Contact.setDoNotText(true)', async () => { + await client.contact.setDoNotText(true, undefined, phone1); + }); + + // 31 — Contact.setDoNotText(false) + await run('31 Contact.setDoNotText(false)', async () => { + await client.contact.setDoNotText(false, undefined, phone1); + }); + + // ── Brand Tests (32-36) ────────────────────────────────────────────────────── + console.log('\n--- Brands ---'); + + let brandId: number | null = null; + + // 32 — Brands.create + await run('32 Brands.create', async () => { + const resp = await client.brands.create({ + legalCompanyName: 'Test Company LLC', + entityType: 'PRIVATE_PROFIT', + taxId: '123456789', + taxIdCountry: 'US', + country: 'US', + verticalType: 'TECHNOLOGY', + websiteUrl: 'https://example.com', + street: '123 Main St', + city: 'Miami', + state: 'FL', + postalCode: '33101', + contactFirstName: firstName1, + contactLastName: lastName1, + contactEmail: email1, + contactPhone: phone1, + }); + if (!resp?.id) throw new Error('Invalid brand id'); + brandId = resp.id; + }); + + // 33 — Brands.get + await run('33 Brands.get', async () => { + if (!brandId) throw new Error('dependency test 32 failed'); + const resp = await client.brands.get(brandId); + if (resp?.id !== brandId) throw new Error('Brand id mismatch'); + }); + + // 34 — Brands.list + await run('34 Brands.list', async () => { + const resp = await client.brands.list(); + if (!Array.isArray(resp)) throw new Error('Expected an array'); + }); + + // 35 — Brands.update + await run('35 Brands.update', async () => { + if (!brandId) throw new Error('dependency test 32 failed'); + const resp = await client.brands.update(brandId, { city: 'Orlando' }); + if (resp?.id !== brandId) throw new Error('Brand id mismatch after update'); + }); + + // 36 — Brands.delete + await run('36 Brands.delete', async () => { + if (!brandId) throw new Error('dependency test 32 failed'); + await client.brands.delete(brandId); + }); + + // ── Campaign Tests (37-42) ──────────────────────────────────────────────────── + console.log('\n--- Campaigns ---'); + + let campaignBrandId: number | null = null; + let campaignId: number | null = null; + + // 37 — Campaign setup: create brand + await run('37 Campaign setup — Brands.create', async () => { + const resp = await client.brands.create({ + legalCompanyName: 'Campaign Test LLC', + entityType: 'PRIVATE_PROFIT', + taxId: '987654321', + taxIdCountry: 'US', + country: 'US', + verticalType: 'TECHNOLOGY', + websiteUrl: 'https://example.com', + street: '456 Test Ave', + city: 'Miami', + state: 'FL', + postalCode: '33101', + contactFirstName: firstName1, + contactLastName: lastName1, + contactEmail: email1, + contactPhone: phone1, + }); + if (!resp?.id) throw new Error('Invalid brand id'); + campaignBrandId = resp.id; + }); + + // 38 — Campaigns.create + await run('38 Campaigns.create', async () => { + if (!campaignBrandId) throw new Error('dependency test 37 failed'); + const resp = await client.campaigns.create({ + brandId: campaignBrandId, + useCase: 'MARKETING', + description: 'Integration test campaign for automated testing', + messageFlow: 'Customers opt-in via website form at https://example.com/sms-signup', + hasEmbeddedLinks: false, + hasEmbeddedPhone: false, + isAgeGated: false, + isDirectLending: false, + optInKeywords: ['START', 'YES'], + optInMessage: 'You have opted in to receive messages. Reply STOP to unsubscribe.', + optInProofUrl: 'https://example.com/opt-in-proof', + helpKeywords: ['HELP', 'INFO'], + helpMessage: 'For help reply HELP or call 1-800-555-0000.', + optOutKeywords: ['STOP', 'END'], + optOutMessage: 'You have been unsubscribed. Reply START to opt back in. STOP', + sampleMessages: [ + 'Hello ${firstName}, this is a test message. Reply STOP to unsubscribe.', + 'Reminder: your appointment is tomorrow. Reply HELP for assistance.', + ], + }); + if (!resp?.id) throw new Error('Invalid campaign id'); + campaignId = resp.id; + }); + + // 39 — Campaigns.get + await run('39 Campaigns.get', async () => { + if (!campaignId) throw new Error('dependency test 38 failed'); + const resp = await client.campaigns.get(campaignId); + if (resp?.id !== campaignId) throw new Error('Campaign id mismatch'); + }); + + // 40 — Campaigns.list + await run('40 Campaigns.list', async () => { + const resp = await client.campaigns.list(); + if (!Array.isArray(resp)) throw new Error('Expected an array'); + }); + + // 41 — Campaigns.update + await run('41 Campaigns.update', async () => { + if (!campaignId) throw new Error('dependency test 38 failed'); + const resp = await client.campaigns.update(campaignId, { + description: 'Updated integration test campaign description', + }); + if (resp?.id !== campaignId) throw new Error('Campaign id mismatch after update'); + }); + + // 42 — Campaigns.delete + cleanup brand + await run('42 Campaigns.delete', async () => { + if (!campaignId) throw new Error('dependency test 38 failed'); + await client.campaigns.delete(campaignId); + if (campaignBrandId) await client.brands.delete(campaignBrandId); + }); + + // ── Contact Validator ──────────────────────────────────────────────────────── + + // 43 — ContactValidator.validateEmail + await run('43 ContactValidator.validateEmail', async () => { + const resp = await client.contactValidator.validateEmail(email1); + if (!resp?.status) throw new Error('status is empty'); + }); + + // 44 — ContactValidator.validateEmails + await run('44 ContactValidator.validateEmails', async () => { + const resp = await client.contactValidator.validateEmails([email1, email2]); + if (resp?.summary?.total !== 2) throw new Error(`expected summary.total=2, got ${resp?.summary?.total}`); + }); + + // 45 — ContactValidator.validatePhone + await run('45 ContactValidator.validatePhone', async () => { + const resp = await client.contactValidator.validatePhone(phone1); + if (!resp?.status) throw new Error('status is empty'); + }); + + // 46 — ContactValidator.validatePhones + await run('46 ContactValidator.validatePhones', async () => { + const resp = await client.contactValidator.validatePhones([{ phone: phone1 }, { phone: phone2 }]); + if (resp?.summary?.total !== 2) throw new Error(`expected summary.total=2, got ${resp?.summary?.total}`); + }); + + // ── Cleanup & Results ───────────────────────────────────────────────────────── + fs.unlinkSync(pngPath); + + console.log('\n=============================================='); + console.log(` RESULTS: ${passed} passed, ${failed} failed`); + console.log('=============================================='); + + const summary = JSON.stringify({ sdk: 'node', passed, failed, total: passed + failed }); + console.log(`\nSUMMARY_JSON: ${summary}`); + + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error('Unexpected error:', err); + process.exit(2); +}); diff --git a/integration/tsconfig.json b/integration/tsconfig.json new file mode 100644 index 0000000..23ca900 --- /dev/null +++ b/integration/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": false, + "skipLibCheck": true, + "outDir": "./dist" + }, + "include": ["test.ts"] +} diff --git a/package.json b/package.json index 1f89729..a4bf973 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccai-node", - "version": "1.0.1", + "version": "1.1.0", "description": "TypeScript client for CloudContactAI API", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/__tests__/brands.test.ts b/src/__tests__/brands.test.ts new file mode 100644 index 0000000..8a5fe49 --- /dev/null +++ b/src/__tests__/brands.test.ts @@ -0,0 +1,187 @@ +import { Brands } from '../brands/brands'; +import { CCAI } from '../ccai'; + +const mockCustomRequest = jest.fn(); + +const mockCcai = { + getClientId: () => 'client-123', + getApiKey: () => 'api-key-456', + getComplianceBaseUrl: () => 'https://compliance-test-cloudcontactai.allcode.com/api', + customRequest: mockCustomRequest, +} as unknown as CCAI; + +const validBrand = { + legalCompanyName: 'Test Corp', + entityType: 'PRIVATE_PROFIT', + taxId: '123456789', + taxIdCountry: 'US', + country: 'US', + verticalType: 'TECHNOLOGY', + websiteUrl: 'https://test.com', + street: '123 Main St', + city: 'Austin', + state: 'TX', + postalCode: '78701', + contactFirstName: 'John', + contactLastName: 'Doe', + contactEmail: 'john@test.com', + contactPhone: '+15551234567', +}; + +const mockResponse = { + id: 1, + accountId: 42, + ...validBrand, + websiteMatchScore: null, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', +}; + +describe('Brands Service', () => { + let brands: Brands; + + beforeEach(() => { + jest.clearAllMocks(); + brands = new Brands(mockCcai); + mockCustomRequest.mockResolvedValue(mockResponse); + }); + + describe('create()', () => { + it('should create a brand with valid data', async () => { + const result = await brands.create(validBrand); + expect(mockCustomRequest).toHaveBeenCalledWith( + 'post', + '/v1/brands', + validBrand, + 'https://compliance-test-cloudcontactai.allcode.com/api' + ); + expect(result).toEqual(mockResponse); + }); + + it('should throw if required fields are missing', async () => { + await expect(brands.create({})).rejects.toThrow('Validation failed'); + }); + + it('should throw for invalid entityType', async () => { + await expect(brands.create({ ...validBrand, entityType: 'INVALID' })).rejects.toThrow( + 'Invalid entity type' + ); + }); + + it('should throw for invalid verticalType', async () => { + await expect(brands.create({ ...validBrand, verticalType: 'INVALID' })).rejects.toThrow( + 'Invalid vertical type' + ); + }); + + it('should throw for invalid taxIdCountry', async () => { + await expect(brands.create({ ...validBrand, taxIdCountry: 'XX' })).rejects.toThrow( + 'Invalid tax ID country' + ); + }); + + it('should throw for invalid websiteUrl', async () => { + await expect(brands.create({ ...validBrand, websiteUrl: 'not-a-url' })).rejects.toThrow( + 'Website URL must start with' + ); + }); + + it('should throw for invalid contactEmail', async () => { + await expect(brands.create({ ...validBrand, contactEmail: 'bad-email' })).rejects.toThrow( + 'Invalid email format' + ); + }); + + it('should throw for US taxId not 9 digits', async () => { + await expect(brands.create({ ...validBrand, taxId: '12345' })).rejects.toThrow( + 'Tax ID must be exactly 9 digits' + ); + }); + + it('should throw if PUBLIC_PROFIT missing stockSymbol', async () => { + await expect(brands.create({ ...validBrand, entityType: 'PUBLIC_PROFIT' })).rejects.toThrow( + 'Stock symbol is required' + ); + }); + + it('should throw for invalid stockExchange', async () => { + await expect( + brands.create({ + ...validBrand, + entityType: 'PUBLIC_PROFIT', + stockSymbol: 'TST', + stockExchange: 'INVALID', + }) + ).rejects.toThrow('Invalid stock exchange'); + }); + + it('should accept PUBLIC_PROFIT with stock fields', async () => { + const data = { + ...validBrand, + entityType: 'PUBLIC_PROFIT', + stockSymbol: 'TST', + stockExchange: 'NASDAQ', + }; + await brands.create(data); + expect(mockCustomRequest).toHaveBeenCalled(); + }); + }); + + describe('get()', () => { + it('should get a brand by ID', async () => { + const result = await brands.get(1); + expect(mockCustomRequest).toHaveBeenCalledWith( + 'get', + '/v1/brands/1', + undefined, + 'https://compliance-test-cloudcontactai.allcode.com/api' + ); + expect(result).toEqual(mockResponse); + }); + }); + + describe('list()', () => { + it('should list all brands', async () => { + mockCustomRequest.mockResolvedValue([mockResponse]); + const result = await brands.list(); + expect(mockCustomRequest).toHaveBeenCalledWith( + 'get', + '/v1/brands', + undefined, + 'https://compliance-test-cloudcontactai.allcode.com/api' + ); + expect(result).toEqual([mockResponse]); + }); + }); + + describe('update()', () => { + it('should update a brand with partial data', async () => { + const data = { street: '456 Oak Ave' }; + await brands.update(1, data); + expect(mockCustomRequest).toHaveBeenCalledWith( + 'patch', + '/v1/brands/1', + data, + 'https://compliance-test-cloudcontactai.allcode.com/api' + ); + }); + + it('should validate fields present in update', async () => { + await expect(brands.update(1, { entityType: 'INVALID' })).rejects.toThrow( + 'Invalid entity type' + ); + }); + }); + + describe('delete()', () => { + it('should delete a brand', async () => { + await brands.delete(1); + expect(mockCustomRequest).toHaveBeenCalledWith( + 'delete', + '/v1/brands/1', + undefined, + 'https://compliance-test-cloudcontactai.allcode.com/api' + ); + }); + }); +}); diff --git a/src/__tests__/campaigns.test.ts b/src/__tests__/campaigns.test.ts new file mode 100644 index 0000000..969e8e5 --- /dev/null +++ b/src/__tests__/campaigns.test.ts @@ -0,0 +1,211 @@ +import { Campaigns } from '../campaigns/campaigns'; +import { CCAI } from '../ccai'; + +const mockCustomRequest = jest.fn(); + +const mockCcai = { + getClientId: () => 'client-123', + getApiKey: () => 'api-key-456', + getComplianceBaseUrl: () => 'https://compliance-test-cloudcontactai.allcode.com/api', + customRequest: mockCustomRequest, +} as unknown as CCAI; + +const validCampaign = { + brandId: 1, + useCase: 'MIXED', + subUseCases: ['CUSTOMER_CARE', 'TWO_FACTOR_AUTHENTICATION', 'ACCOUNT_NOTIFICATION'], + description: 'Test campaign', + messageFlow: 'Users opt-in via signup form at https://example.com/signup', + hasEmbeddedLinks: true, + hasEmbeddedPhone: false, + isAgeGated: false, + isDirectLending: false, + optInKeywords: ['START'], + optInMessage: 'Welcome! Reply STOP to cancel.', + optInProofUrl: 'https://example.com/opt-in.png', + helpKeywords: ['HELP'], + helpMessage: 'For HELP email support@example.com.', + optOutKeywords: ['STOP'], + optOutMessage: 'STOP received. You are unsubscribed.', + sampleMessages: [ + 'Your code is 554321. Reply STOP to cancel.', + 'Your ticket has been updated. Reply HELP for info.', + ], +}; + +const mockResponse = { + id: 1, + accountId: 42, + ...validCampaign, + monthlyFee: 10, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', +}; + +describe('Campaigns Service', () => { + let campaigns: Campaigns; + + beforeEach(() => { + jest.clearAllMocks(); + campaigns = new Campaigns(mockCcai); + mockCustomRequest.mockResolvedValue(mockResponse); + }); + + describe('create()', () => { + it('should create a campaign with valid data', async () => { + const result = await campaigns.create(validCampaign); + expect(mockCustomRequest).toHaveBeenCalledWith( + 'post', + '/v1/campaigns', + validCampaign, + 'https://compliance-test-cloudcontactai.allcode.com/api' + ); + expect(result).toEqual(mockResponse); + }); + + it('should throw if required fields are missing', async () => { + await expect(campaigns.create({})).rejects.toThrow('Validation failed'); + }); + + it('should throw for invalid useCase', async () => { + await expect(campaigns.create({ ...validCampaign, useCase: 'INVALID' })).rejects.toThrow( + 'Invalid use case' + ); + }); + + it('should throw if MIXED has fewer than 2 subUseCases', async () => { + await expect( + campaigns.create({ ...validCampaign, subUseCases: ['CUSTOMER_CARE'] }) + ).rejects.toThrow('2-3 sub use cases'); + }); + + it('should throw if MIXED has invalid subUseCase', async () => { + await expect( + campaigns.create({ + ...validCampaign, + subUseCases: ['CUSTOMER_CARE', 'INVALID', 'MARKETING'], + }) + ).rejects.toThrow('Invalid sub use case'); + }); + + it('should throw if non-MIXED has subUseCases', async () => { + await expect( + campaigns.create({ + ...validCampaign, + useCase: 'MARKETING', + subUseCases: ['CUSTOMER_CARE', 'FRAUD_ALERT'], + }) + ).rejects.toThrow('subUseCases should be empty'); + }); + + it('should throw if sampleMessages has fewer than 2', async () => { + await expect(campaigns.create({ ...validCampaign, sampleMessages: ['one'] })).rejects.toThrow( + '2-5 items' + ); + }); + + it('should throw if sampleMessages missing STOP', async () => { + await expect( + campaigns.create({ + ...validCampaign, + sampleMessages: ['Hello Reply HELP for info.', 'Another Reply HELP msg'], + }) + ).rejects.toThrow('Reply STOP'); + }); + + it('should throw if sampleMessages missing HELP', async () => { + await expect( + campaigns.create({ + ...validCampaign, + sampleMessages: ['Reply STOP to cancel.', 'Another Reply STOP msg'], + }) + ).rejects.toThrow('Reply HELP'); + }); + + it('should throw if optOutMessage missing STOP keyword', async () => { + await expect( + campaigns.create({ ...validCampaign, optOutMessage: 'You are unsubscribed.' }) + ).rejects.toThrow('optOutMessage must contain'); + }); + + it('should throw if helpMessage missing HELP keyword', async () => { + await expect( + campaigns.create({ ...validCampaign, helpMessage: 'Email support@example.com.' }) + ).rejects.toThrow('helpMessage must contain'); + }); + + it('should throw if optInProofUrl is not http(s)', async () => { + await expect( + campaigns.create({ ...validCampaign, optInProofUrl: 'ftp://bad.com' }) + ).rejects.toThrow('optInProofUrl must start with'); + }); + + it('should throw if termsLink is not http(s)', async () => { + await expect( + campaigns.create({ ...validCampaign, termsLink: 'ftp://bad.com' }) + ).rejects.toThrow('termsLink must start with'); + }); + + it('should throw if privacyLink is not http(s)', async () => { + await expect( + campaigns.create({ ...validCampaign, privacyLink: 'ftp://bad.com' }) + ).rejects.toThrow('privacyLink must start with'); + }); + }); + + describe('get()', () => { + it('should get a campaign by ID', async () => { + const result = await campaigns.get(1); + expect(mockCustomRequest).toHaveBeenCalledWith( + 'get', + '/v1/campaigns/1', + undefined, + 'https://compliance-test-cloudcontactai.allcode.com/api' + ); + expect(result).toEqual(mockResponse); + }); + }); + + describe('list()', () => { + it('should list all campaigns', async () => { + mockCustomRequest.mockResolvedValue([mockResponse]); + const result = await campaigns.list(); + expect(mockCustomRequest).toHaveBeenCalledWith( + 'get', + '/v1/campaigns', + undefined, + 'https://compliance-test-cloudcontactai.allcode.com/api' + ); + expect(result).toEqual([mockResponse]); + }); + }); + + describe('update()', () => { + it('should update a campaign with partial data', async () => { + const data = { description: 'Updated' }; + await campaigns.update(1, data); + expect(mockCustomRequest).toHaveBeenCalledWith( + 'patch', + '/v1/campaigns/1', + data, + 'https://compliance-test-cloudcontactai.allcode.com/api' + ); + }); + + it('should validate fields present in update', async () => { + await expect(campaigns.update(1, { useCase: 'INVALID' })).rejects.toThrow('Invalid use case'); + }); + }); + + describe('delete()', () => { + it('should delete a campaign', async () => { + await campaigns.delete(1); + expect(mockCustomRequest).toHaveBeenCalledWith( + 'delete', + '/v1/campaigns/1', + undefined, + 'https://compliance-test-cloudcontactai.allcode.com/api' + ); + }); + }); +}); diff --git a/src/brands/brands.ts b/src/brands/brands.ts new file mode 100644 index 0000000..65f07a5 --- /dev/null +++ b/src/brands/brands.ts @@ -0,0 +1,206 @@ +/** + * Brand service for managing brand registrations via CloudContactAI API + * + * @license MIT + * @copyright 2025 CloudContactAI LLC + */ + +import { CCAI } from '../ccai'; + +export type BrandData = { + legalCompanyName?: string; + dba?: string; + entityType?: string; + taxId?: string; + taxIdCountry?: string; + country?: string; + verticalType?: string; + websiteUrl?: string; + stockSymbol?: string; + stockExchange?: string; + street?: string; + city?: string; + state?: string; + postalCode?: string; + contactFirstName?: string; + contactLastName?: string; + contactEmail?: string; + contactPhone?: string; + websiteMatch?: boolean; +}; + +export type BrandResponse = BrandData & { + id: number; + accountId: number; + websiteMatchScore: number | null; + createdAt: string; + updatedAt: string; +}; + +const ENTITY_TYPES = new Set([ + 'PRIVATE_PROFIT', + 'PUBLIC_PROFIT', + 'NON_PROFIT', + 'GOVERNMENT', + 'SOLE_PROPRIETOR', +]); +const VERTICAL_TYPES = new Set([ + 'AUTOMOTIVE', + 'AGRICULTURE', + 'BANKING', + 'COMMUNICATION', + 'CONSTRUCTION', + 'EDUCATION', + 'ENERGY', + 'ENTERTAINMENT', + 'GOVERNMENT', + 'HEALTHCARE', + 'HOSPITALITY', + 'INSURANCE', + 'LEGAL', + 'MANUFACTURING', + 'NON_PROFIT', + 'PROFESSIONAL', + 'REAL_ESTATE', + 'RETAIL', + 'TECHNOLOGY', + 'TRANSPORTATION', +]); +const TAX_ID_COUNTRIES = new Set(['US', 'CA', 'GB', 'AU']); +const STOCK_EXCHANGES = new Set(['NASDAQ', 'NYSE', 'AMEX', 'TSX', 'LON', 'JPX', 'HKEX', 'OTHER']); + +function validateBrand(data: BrandData, isCreate: boolean): void { + const errors: { field: string; message: string }[] = []; + + if (isCreate) { + const required = [ + 'legalCompanyName', + 'entityType', + 'taxId', + 'taxIdCountry', + 'country', + 'verticalType', + 'websiteUrl', + 'street', + 'city', + 'state', + 'postalCode', + 'contactFirstName', + 'contactLastName', + 'contactEmail', + 'contactPhone', + ] as const; + for (const field of required) { + if (!data[field]) errors.push({ field, message: `${field} is required` }); + } + } + + if (data.entityType && !ENTITY_TYPES.has(data.entityType)) + errors.push({ field: 'entityType', message: 'Invalid entity type' }); + if (data.verticalType && !VERTICAL_TYPES.has(data.verticalType)) + errors.push({ field: 'verticalType', message: 'Invalid vertical type' }); + if (data.taxIdCountry && !TAX_ID_COUNTRIES.has(data.taxIdCountry)) + errors.push({ field: 'taxIdCountry', message: 'Invalid tax ID country' }); + if (data.stockExchange && !STOCK_EXCHANGES.has(data.stockExchange)) + errors.push({ field: 'stockExchange', message: 'Invalid stock exchange' }); + + if ( + data.websiteUrl && + !data.websiteUrl.startsWith('http://') && + !data.websiteUrl.startsWith('https://') + ) { + errors.push({ + field: 'websiteUrl', + message: 'Website URL must start with http:// or https://', + }); + } + + if (data.contactEmail && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(data.contactEmail)) { + errors.push({ field: 'contactEmail', message: 'Invalid email format' }); + } + + if ( + data.taxId && + data.taxIdCountry && + (data.taxIdCountry === 'US' || data.taxIdCountry === 'CA') + ) { + if (!/^\d{9}$/.test(data.taxId)) { + errors.push({ + field: 'taxId', + message: `Tax ID must be exactly 9 digits for ${data.taxIdCountry}`, + }); + } + } + + if (data.entityType === 'PUBLIC_PROFIT') { + if (!data.stockSymbol) + errors.push({ + field: 'stockSymbol', + message: 'Stock symbol is required for PUBLIC_PROFIT entities', + }); + if (!data.stockExchange) + errors.push({ + field: 'stockExchange', + message: 'Stock exchange is required for PUBLIC_PROFIT entities', + }); + } + + if (errors.length > 0) { + throw new Error(`Validation failed: ${JSON.stringify(errors)}`); + } +} + +export class Brands { + private ccai: CCAI; + + constructor(ccai: CCAI) { + this.ccai = ccai; + } + + async create(data: BrandData): Promise