From f392aa433d53c70e9db545d359e5ea0544daf76d Mon Sep 17 00:00:00 2001 From: Mike Alvarez Date: Tue, 5 May 2026 15:00:18 -0500 Subject: [PATCH] Adding support for campaigns and brands management. --- .github/workflows/ci.yml | 6 - .gitignore | 3 +- README.md | 126 ++++++++++++++ src/__tests__/brands.test.ts | 187 +++++++++++++++++++++ src/__tests__/campaigns.test.ts | 211 +++++++++++++++++++++++ src/brands/brands.ts | 206 +++++++++++++++++++++++ src/campaigns/campaigns.ts | 270 ++++++++++++++++++++++++++++++ src/ccai.ts | 26 +++ src/examples/brands-example.ts | 76 +++++++++ src/examples/campaigns-example.ts | 83 +++++++++ src/index.ts | 21 ++- 11 files changed, 1207 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/brands.test.ts create mode 100644 src/__tests__/campaigns.test.ts create mode 100644 src/brands/brands.ts create mode 100644 src/campaigns/campaigns.ts create mode 100644 src/examples/brands-example.ts create mode 100644 src/examples/campaigns-example.ts 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/.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..fd848d8 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,130 @@ async function sendMmsWithImage() { sendMmsWithImage(); ``` +### 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); + +// 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 @@ -542,6 +666,8 @@ 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) - Webhook management: register, list, update, delete - Webhook signature verification (HMAC-SHA256) 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 { + validateBrand(data, true); + return this.ccai.customRequest( + 'post', + '/v1/brands', + data, + this.ccai.getComplianceBaseUrl() + ); + } + + async get(id: number): Promise { + return this.ccai.customRequest( + 'get', + `/v1/brands/${id}`, + undefined, + this.ccai.getComplianceBaseUrl() + ); + } + + async list(): Promise { + return this.ccai.customRequest( + 'get', + '/v1/brands', + undefined, + this.ccai.getComplianceBaseUrl() + ); + } + + async update(id: number, data: BrandData): Promise { + validateBrand(data, false); + return this.ccai.customRequest( + 'patch', + `/v1/brands/${id}`, + data, + this.ccai.getComplianceBaseUrl() + ); + } + + async delete(id: number): Promise { + await this.ccai.customRequest( + 'delete', + `/v1/brands/${id}`, + undefined, + this.ccai.getComplianceBaseUrl() + ); + } +} diff --git a/src/campaigns/campaigns.ts b/src/campaigns/campaigns.ts new file mode 100644 index 0000000..e150aab --- /dev/null +++ b/src/campaigns/campaigns.ts @@ -0,0 +1,270 @@ +/** + * Campaign service for managing campaign registrations via CloudContactAI API + * + * @license MIT + * @copyright 2025 CloudContactAI LLC + */ + +import { CCAI } from '../ccai'; + +export type CampaignData = { + brandId?: number; + useCase?: string; + subUseCases?: string[]; + description?: string; + messageFlow?: string; + termsLink?: string; + privacyLink?: string; + hasEmbeddedLinks?: boolean; + hasEmbeddedPhone?: boolean; + isAgeGated?: boolean; + isDirectLending?: boolean; + optInKeywords?: string[]; + optInMessage?: string; + optInProofUrl?: string; + helpKeywords?: string[]; + helpMessage?: string; + optOutKeywords?: string[]; + optOutMessage?: string; + sampleMessages?: string[]; +}; + +export type CampaignResponse = CampaignData & { + id: number; + accountId: number; + monthlyFee: number; + createdAt: string; + updatedAt: string; +}; + +const CAMPAIGN_USE_CASES = new Set([ + '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', +]); + +const CAMPAIGN_SUB_USE_CASES = new Set([ + 'TWO_FACTOR_AUTHENTICATION', + 'ACCOUNT_NOTIFICATION', + 'CUSTOMER_CARE', + 'DELIVERY_NOTIFICATION', + 'FRAUD_ALERT', + 'MARKETING', + 'POLLING_VOTING', +]); + +const MIXED_USE_CASES = new Set(['MIXED', 'LOW_VOLUME_MIXED']); + +const REQUIRED_FIELDS = [ + 'brandId', + 'useCase', + 'description', + 'messageFlow', + 'hasEmbeddedLinks', + 'hasEmbeddedPhone', + 'isAgeGated', + 'isDirectLending', + 'optInKeywords', + 'optInMessage', + 'optInProofUrl', + 'helpKeywords', + 'helpMessage', + 'optOutKeywords', + 'optOutMessage', + 'sampleMessages', +] as const; + +function validateCampaign(data: CampaignData, isCreate: boolean): void { + const errors: { field: string; message: string }[] = []; + + if (isCreate) { + for (const field of REQUIRED_FIELDS) { + const value = (data as Record)[field]; + if (value === undefined || value === null || value === '') { + errors.push({ field, message: `${field} is required` }); + } + } + } + + if (data.useCase && !CAMPAIGN_USE_CASES.has(data.useCase)) { + errors.push({ field: 'useCase', message: 'Invalid use case' }); + } + + const useCase = data.useCase; + const subUseCases = data.subUseCases; + + if (useCase && MIXED_USE_CASES.has(useCase)) { + if ( + !subUseCases || + !Array.isArray(subUseCases) || + subUseCases.length < 2 || + subUseCases.length > 3 + ) { + errors.push({ + field: 'subUseCases', + message: 'MIXED/LOW_VOLUME_MIXED requires 2-3 sub use cases', + }); + } else { + for (const suc of subUseCases) { + if (!CAMPAIGN_SUB_USE_CASES.has(suc)) { + errors.push({ field: 'subUseCases', message: `Invalid sub use case: ${suc}` }); + } + } + } + } else if (useCase && subUseCases) { + errors.push({ + field: 'subUseCases', + message: 'subUseCases should be empty for non-MIXED use cases', + }); + } + + if (data.sampleMessages !== undefined) { + if ( + !Array.isArray(data.sampleMessages) || + data.sampleMessages.length < 2 || + data.sampleMessages.length > 5 + ) { + errors.push({ field: 'sampleMessages', message: 'sampleMessages must contain 2-5 items' }); + } else { + const optOutKeywords = data.optOutKeywords || []; + const helpKeywords = data.helpKeywords || []; + + const hasOptOut = data.sampleMessages.some( + (msg) => + msg.includes('Reply STOP') || optOutKeywords.some((kw) => msg.includes(`Reply ${kw}`)) + ); + if (!hasOptOut) { + errors.push({ + field: 'sampleMessages', + message: "At least one sample must contain 'Reply STOP' or 'Reply {optOutKeyword}'", + }); + } + + const hasHelp = data.sampleMessages.some( + (msg) => + msg.includes('Reply HELP') || helpKeywords.some((kw) => msg.includes(`Reply ${kw}`)) + ); + if (!hasHelp) { + errors.push({ + field: 'sampleMessages', + message: "At least one sample must contain 'Reply HELP' or 'Reply {helpKeyword}'", + }); + } + } + } + + if (data.optOutMessage !== undefined) { + const optOutKeywords = data.optOutKeywords || []; + if ( + !data.optOutMessage.includes('STOP') && + !optOutKeywords.some((kw) => data.optOutMessage?.includes(kw)) + ) { + errors.push({ + field: 'optOutMessage', + message: "optOutMessage must contain 'STOP' or at least one optOutKeyword", + }); + } + } + + if (data.helpMessage !== undefined) { + const helpKeywords = data.helpKeywords || []; + if ( + !data.helpMessage.includes('HELP') && + !helpKeywords.some((kw) => data.helpMessage?.includes(kw)) + ) { + errors.push({ + field: 'helpMessage', + message: "helpMessage must contain 'HELP' or at least one helpKeyword", + }); + } + } + + if (data.optInProofUrl !== undefined) { + if (!data.optInProofUrl.startsWith('http://') && !data.optInProofUrl.startsWith('https://')) { + errors.push({ + field: 'optInProofUrl', + message: 'optInProofUrl must start with http:// or https://', + }); + } + } + + for (const linkField of ['termsLink', 'privacyLink'] as const) { + const val = data[linkField]; + if (val) { + if (!val.startsWith('http://') && !val.startsWith('https://')) { + errors.push({ + field: linkField, + message: `${linkField} must start with http:// or https://`, + }); + } + } + } + + if (errors.length > 0) { + throw new Error(`Validation failed: ${JSON.stringify(errors)}`); + } +} + +export class Campaigns { + private ccai: CCAI; + + constructor(ccai: CCAI) { + this.ccai = ccai; + } + + async create(data: CampaignData): Promise { + validateCampaign(data, true); + return this.ccai.customRequest( + 'post', + '/v1/campaigns', + data, + this.ccai.getComplianceBaseUrl() + ); + } + + async get(id: number): Promise { + return this.ccai.customRequest( + 'get', + `/v1/campaigns/${id}`, + undefined, + this.ccai.getComplianceBaseUrl() + ); + } + + async list(): Promise { + return this.ccai.customRequest( + 'get', + '/v1/campaigns', + undefined, + this.ccai.getComplianceBaseUrl() + ); + } + + async update(id: number, data: CampaignData): Promise { + validateCampaign(data, false); + return this.ccai.customRequest( + 'patch', + `/v1/campaigns/${id}`, + data, + this.ccai.getComplianceBaseUrl() + ); + } + + async delete(id: number): Promise { + await this.ccai.customRequest( + 'delete', + `/v1/campaigns/${id}`, + undefined, + this.ccai.getComplianceBaseUrl() + ); + } +} diff --git a/src/ccai.ts b/src/ccai.ts index dcc427b..792afb3 100644 --- a/src/ccai.ts +++ b/src/ccai.ts @@ -8,6 +8,8 @@ */ import axios, { AxiosResponse } from 'axios'; +import { Brands } from './brands/brands'; +import { Campaigns } from './campaigns/campaigns'; import { Contact } from './contact/contact'; import { Email } from './email/email'; import { MMS } from './sms/mms'; @@ -18,11 +20,13 @@ import { Webhook } from './webhook/webhook'; 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'; +const PROD_COMPLIANCE_URL = 'https://compliance.cloudcontactai.com/api'; // 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'; +const TEST_COMPLIANCE_URL = 'https://compliance-test-cloudcontactai.allcode.com/api'; /** * Account representing a message recipient. @@ -73,6 +77,8 @@ export type CCAIConfig = { emailBaseUrl?: string; /** Override base URL for the Files API */ filesBaseUrl?: string; + /** Override base URL for the Compliance API */ + complianceBaseUrl?: string; }; /** @@ -84,6 +90,7 @@ export class CCAI { private baseUrl: string; private emailBaseUrl: string; private filesBaseUrl: string; + private complianceBaseUrl: string; private useTestEnvironment: boolean; /** SMS service for sending text messages */ @@ -97,6 +104,8 @@ export class CCAI { /** Webhook service for managing webhook endpoints */ public webhook: Webhook; + public brands: Brands; + public campaigns: Campaigns; /** Contact service for managing contact preferences */ public contact: Contact; @@ -135,12 +144,21 @@ export class CCAI { TEST_FILES_URL ); + this.complianceBaseUrl = this.resolveUrl( + config.complianceBaseUrl, + process.env.CCAI_COMPLIANCE_BASE_URL, + PROD_COMPLIANCE_URL, + TEST_COMPLIANCE_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); + this.brands = new Brands(this); + this.campaigns = new Campaigns(this); } /** @@ -197,6 +215,14 @@ export class CCAI { return this.filesBaseUrl; } + /** + * Get the base URL for the Compliance API + * @returns The compliance base URL + */ + getComplianceBaseUrl(): string { + return this.complianceBaseUrl; + } + /** * Whether the test environment is active * @returns True if using test environment diff --git a/src/examples/brands-example.ts b/src/examples/brands-example.ts new file mode 100644 index 0000000..7414e22 --- /dev/null +++ b/src/examples/brands-example.ts @@ -0,0 +1,76 @@ +/** + * Example usage of the CCAI Brands API + * + * @license MIT + * @copyright 2025 CloudContactAI LLC + */ + +import dotenv from 'dotenv'; +import { CCAI } from '../ccai'; + +// Load environment variables +dotenv.config(); + +// Create a new CCAI client +const ccai = new CCAI({ + clientId: process.env.CCAI_CLIENT_ID || '', + apiKey: process.env.CCAI_API_KEY || '', +}); + +async function brandExamples() { + try { + // Create a brand + console.log('Creating 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); + + // Get brand by ID + console.log('\nFetching brand by ID...'); + const fetched = await ccai.brands.get(brand.id); + console.log('Brand details:', fetched); + + // List all brands + console.log('\nListing all brands...'); + const brands = await ccai.brands.list(); + console.log(`Found ${brands.length} brand(s)`); + + // Update a brand + console.log('\nUpdating brand...'); + const updated = await ccai.brands.update(brand.id, { + street: '456 Oak Avenue', + city: 'Los Angeles', + contactEmail: 'admin@collect.org', + }); + console.log('Brand updated:', updated); + + // Delete a brand + console.log('\nDeleting brand...'); + await ccai.brands.delete(brand.id); + console.log('Brand deleted successfully'); + } catch (error: unknown) { + if (error instanceof Error) { + console.error('Error:', error.message); + } else { + console.error('Error:', error); + } + } +} + +brandExamples(); diff --git a/src/examples/campaigns-example.ts b/src/examples/campaigns-example.ts new file mode 100644 index 0000000..aa74418 --- /dev/null +++ b/src/examples/campaigns-example.ts @@ -0,0 +1,83 @@ +/** + * Example usage of the CCAI Campaigns API + * + * @license MIT + * @copyright 2025 CloudContactAI LLC + */ + +import dotenv from 'dotenv'; +import { CCAI } from '../ccai'; + +dotenv.config(); + +const ccai = new CCAI({ + clientId: process.env.CCAI_CLIENT_ID || '', + apiKey: process.env.CCAI_API_KEY || '', +}); + +async function campaignExamples() { + try { + // Create a campaign (assumes brand ID 1 exists) + console.log('Creating a campaign...'); + const campaign = await ccai.campaigns.create({ + brandId: 1, + useCase: 'MIXED', + subUseCases: ['CUSTOMER_CARE', 'TWO_FACTOR_AUTHENTICATION', 'ACCOUNT_NOTIFICATION'], + description: 'This campaign handles security codes and support for Collect.org.', + messageFlow: 'Users opt-in via our signup form checkbox at https://collect.org/signup', + termsLink: 'https://collect.org/terms', + privacyLink: 'https://collect.org/privacy', + hasEmbeddedLinks: true, + hasEmbeddedPhone: false, + isAgeGated: false, + isDirectLending: false, + optInKeywords: ['START', 'JOIN'], + optInMessage: 'Welcome to Collect.org! Msg&Data rates may apply. Reply STOP to cancel.', + optInProofUrl: 'https://collect.org/images/opt-in-proof.png', + helpKeywords: ['HELP', 'INFO'], + helpMessage: 'Collect.org: For help email support@collect.org. Reply STOP to cancel.', + optOutKeywords: ['STOP', 'UNSUBSCRIBE'], + optOutMessage: 'Collect.org: You have been unsubscribed. STOP received.', + sampleMessages: [ + 'Your Collect.org security code is 554321. Reply STOP to cancel.', + 'Hi [Name], your ticket #[ID] has been updated. Reply HELP for more info.', + ], + }); + console.log('Campaign created:', campaign.id, `fee: $${campaign.monthlyFee}/mo`); + + // Get campaign by ID + console.log('\nFetching campaign by ID...'); + const fetched = await ccai.campaigns.get(campaign.id); + console.log('Campaign:', fetched.useCase, 'Brand:', fetched.brandId); + + // List all campaigns + console.log('\nListing all campaigns...'); + const campaigns = await ccai.campaigns.list(); + console.log(`Found ${campaigns.length} campaign(s)`); + + // Update a campaign + console.log('\nUpdating campaign...'); + const updated = await ccai.campaigns.update(campaign.id, { + description: 'Updated campaign description for Collect.org messaging.', + sampleMessages: [ + 'Your Collect.org code is 123456. Reply STOP to opt-out.', + 'Your support ticket has been resolved. Reply HELP for more info.', + 'Your payment of $50.00 was received. Reply STOP to cancel.', + ], + }); + console.log('Campaign updated:', updated.description); + + // Delete a campaign + console.log('\nDeleting campaign...'); + await ccai.campaigns.delete(campaign.id); + console.log('Campaign deleted successfully'); + } catch (error: unknown) { + if (error instanceof Error) { + console.error('Error:', error.message); + } else { + console.error('Error:', error); + } + } +} + +campaignExamples(); diff --git a/src/index.ts b/src/index.ts index f536ec9..9a68d8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,10 @@ * @copyright 2025 CloudContactAI LLC */ +import { Brands } from './brands/brands'; +import type { BrandData, BrandResponse } from './brands/brands'; +import { Campaigns } from './campaigns/campaigns'; +import type { CampaignData, CampaignResponse } from './campaigns/campaigns'; import { CCAI } from './ccai'; import type { Account, CCAIConfig } from './ccai'; import { Contact } from './contact/contact'; @@ -22,7 +26,18 @@ import { WebhookEventType } from './webhook/types'; import { Webhook } from './webhook/webhook'; // Re-export classes -export { CCAI, SMS, MMS, Email, Webhook, WebhookEventType, createWebhookHandler, Contact }; +export { + CCAI, + SMS, + MMS, + Email, + Webhook, + WebhookEventType, + createWebhookHandler, + Contact, + Brands, + Campaigns, +}; // Re-export types using 'export type' export type { @@ -40,4 +55,8 @@ export type { WebhookEvent, WebhookHandlerOptions, SetDoNotTextResponse, + BrandData, + BrandResponse, + CampaignData, + CampaignResponse, };