Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/ccai.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
require 'ccai/email/email_service'
require 'ccai/webhook/webhook_service'
require 'ccai/contact/contact_service'
require 'ccai/brand/brand_service'
require 'ccai/campaign/campaign_service'

# Main module for the CCAI Ruby client
module CCAI
Expand Down
105 changes: 105 additions & 0 deletions lib/ccai/brand/brand_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# frozen_string_literal: true

# Copyright (c) 2025 CloudContactAI LLC
# Licensed under the MIT License. See LICENSE in the project root for license information.

module CCAI
module Brand
# Service for managing Brands (10DLC compliance)
class BrandService
# Create a new BrandService instance
#
# @param client [CCAI::Client] The parent CCAI client
def initialize(client)
@client = client
end

# Create a new brand
#
# @param brand [Hash] Brand attributes
# @return [Hash] API response
def create(brand)
validate!(brand)
@client.compliance_request(:post, '/v1/brands', brand)
end

# Get a brand by ID
#
# @param brand_id [String] Brand ID
# @return [Hash] API response
def get(brand_id)
raise ArgumentError, 'brandId is required' if brand_id.nil? || brand_id.to_s.empty?

@client.compliance_request(:get, "/v1/brands/#{brand_id}")
end

# List all brands for the account
#
# @return [Hash] API response
def list
@client.compliance_request(:get, '/v1/brands')
end

# Update a brand
#
# @param brand_id [String] Brand ID
# @param brand [Hash] Updated brand attributes
# @return [Hash] API response
def update(brand_id, brand)
raise ArgumentError, 'brandId is required' if brand_id.nil? || brand_id.to_s.empty?

validate!(brand, is_create: false)
@client.compliance_request(:patch, "/v1/brands/#{brand_id}", brand)
end

# Delete a brand
#
# @param brand_id [String] Brand ID
# @return [Hash] API response
def delete(brand_id)
raise ArgumentError, 'brandId is required' if brand_id.nil? || brand_id.to_s.empty?

@client.compliance_request(:delete, "/v1/brands/#{brand_id}")
end

private

REQUIRED_CREATE_FIELDS = %i[
legalCompanyName entityType taxId taxIdCountry country
verticalType websiteUrl street city state postalCode
contactFirstName contactLastName contactEmail contactPhone
].freeze

def validate!(brand, is_create: true)
if is_create
REQUIRED_CREATE_FIELDS.each do |field|
val = brand[field] || brand[field.to_s]
raise ArgumentError, "#{field} is required" if val.nil? || val.to_s.strip.empty?
end
end

entity_type = brand[:entityType] || brand['entityType']
if entity_type == 'PUBLIC_PROFIT'
stock_symbol = brand[:stockSymbol] || brand['stockSymbol']
stock_exchange = brand[:stockExchange] || brand['stockExchange']
if stock_symbol.nil? || stock_symbol.to_s.empty?
raise ArgumentError, 'stockSymbol is required for PUBLIC_PROFIT entities'
end
if stock_exchange.nil? || stock_exchange.to_s.empty?
raise ArgumentError, 'stockExchange is required for PUBLIC_PROFIT entities'
end
end

website_url = brand[:websiteUrl] || brand['websiteUrl']
if website_url && !website_url.to_s.match?(/^https?:\/\//)
raise ArgumentError, 'websiteUrl must start with http:// or https://'
end

contact_email = brand[:contactEmail] || brand['contactEmail']
if contact_email && !contact_email.to_s.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
raise ArgumentError, 'contactEmail must be a valid email address'
end
end
end
end
end
127 changes: 127 additions & 0 deletions lib/ccai/campaign/campaign_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# frozen_string_literal: true

# Copyright (c) 2025 CloudContactAI LLC
# Licensed under the MIT License. See LICENSE in the project root for license information.

module CCAI
module Campaign
# Service for managing Campaigns (10DLC compliance)
class CampaignService
# Create a new CampaignService instance
#
# @param client [CCAI::Client] The parent CCAI client
def initialize(client)
@client = client
end

# Create a new campaign
#
# @param campaign [Hash] Campaign attributes
# @return [Hash] API response
def create(campaign)
validate!(campaign, is_create: true)
@client.compliance_request(:post, '/v1/campaigns', campaign)
end

# Get a campaign by ID
#
# @param campaign_id [String] Campaign ID
# @return [Hash] API response
def get(campaign_id)
raise ArgumentError, 'campaignId is required' if campaign_id.nil? || campaign_id.to_s.empty?

@client.compliance_request(:get, "/v1/campaigns/#{campaign_id}")
end

# List all campaigns for the account
#
# @return [Hash] API response
def list
@client.compliance_request(:get, '/v1/campaigns')
end

# Update a campaign
#
# @param campaign_id [String] Campaign ID
# @param campaign [Hash] Updated campaign attributes
# @return [Hash] API response
def update(campaign_id, campaign)
raise ArgumentError, 'campaignId is required' if campaign_id.nil? || campaign_id.to_s.empty?

validate!(campaign, is_create: false)
@client.compliance_request(:patch, "/v1/campaigns/#{campaign_id}", campaign)
end

# Delete a campaign
#
# @param campaign_id [String] Campaign ID
# @return [Hash] API response
def delete(campaign_id)
raise ArgumentError, 'campaignId is required' if campaign_id.nil? || campaign_id.to_s.empty?

@client.compliance_request(:delete, "/v1/campaigns/#{campaign_id}")
end

private

REQUIRED_CREATE_FIELDS = %i[
brandId useCase description messageFlow
hasEmbeddedLinks hasEmbeddedPhone isAgeGated isDirectLending
optInKeywords optInMessage optInProofUrl
helpKeywords helpMessage
optOutKeywords optOutMessage
sampleMessages
].freeze

def validate!(campaign, is_create: true)
if is_create
REQUIRED_CREATE_FIELDS.each do |field|
val = campaign.key?(field) ? campaign[field] : campaign[field.to_s]
raise ArgumentError, "#{field} is required" if val.nil?
end

brand_id = campaign.key?(:brandId) ? campaign[:brandId] : campaign['brandId']
raise ArgumentError, 'brandId is required' if brand_id.to_s.strip.empty?
end

sample_messages = campaign.key?(:sampleMessages) ? campaign[:sampleMessages] : campaign['sampleMessages']
if sample_messages
unless sample_messages.is_a?(Array) && sample_messages.length.between?(2, 5)
raise ArgumentError, 'sampleMessages must have between 2 and 5 items'
end

opt_out_keywords = (campaign[:optOutKeywords] || campaign['optOutKeywords'] || []).map(&:upcase)
help_keywords = (campaign[:helpKeywords] || campaign['helpKeywords'] || []).map(&:upcase)

has_stop = sample_messages.any? do |m|
msg = m.to_s.upcase
msg.include?('REPLY STOP') || opt_out_keywords.any? { |kw| msg.include?("REPLY #{kw}") }
end
raise ArgumentError, 'at least one sampleMessage must contain "Reply STOP" or an optOutKeyword' unless has_stop

has_help = sample_messages.any? do |m|
msg = m.to_s.upcase
msg.include?('REPLY HELP') || help_keywords.any? { |kw| msg.include?("REPLY #{kw}") }
end
raise ArgumentError, 'at least one sampleMessage must contain "Reply HELP" or a helpKeyword' unless has_help
end

opt_out_msg = campaign[:optOutMessage] || campaign['optOutMessage']
if opt_out_msg
opt_out_keywords = (campaign[:optOutKeywords] || campaign['optOutKeywords'] || []).map(&:upcase)
unless opt_out_msg.to_s.upcase.include?('STOP') || opt_out_keywords.any? { |kw| opt_out_msg.to_s.upcase.include?(kw) }
raise ArgumentError, 'optOutMessage must contain "STOP" or an optOutKeyword'
end
end

use_case = campaign[:useCase] || campaign['useCase']
if use_case && %w[MIXED LOW_VOLUME_MIXED].include?(use_case.to_s)
sub_use_cases = campaign[:subUseCases] || campaign['subUseCases']
unless sub_use_cases.is_a?(Array) && sub_use_cases.length.between?(2, 3)
raise ArgumentError, 'MIXED/LOW_VOLUME_MIXED campaigns require 2-3 subUseCases'
end
end
end
end
end
end
65 changes: 55 additions & 10 deletions lib/ccai/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,25 @@
require 'ccai/email/email_service'
require 'ccai/webhook/webhook_service'
require 'ccai/contact/contact_service'
require 'ccai/brand/brand_service'
require 'ccai/campaign/campaign_service'

module CCAI
# Configuration for the CCAI client
class Config
attr_reader :client_id, :api_key, :base_url, :email_base_url, :files_base_url, :use_test_environment
attr_reader :client_id, :api_key, :base_url, :email_base_url, :files_base_url, :compliance_base_url, :use_test_environment

# Production URLs
PROD_BASE_URL = 'https://core.cloudcontactai.com/api'
PROD_EMAIL_URL = 'https://email-campaigns.cloudcontactai.com/api/v1'
PROD_FILES_URL = 'https://files.cloudcontactai.com'
PROD_BASE_URL = 'https://core.cloudcontactai.com/api'
PROD_EMAIL_URL = 'https://email-campaigns.cloudcontactai.com/api/v1'
PROD_FILES_URL = 'https://files.cloudcontactai.com'
PROD_COMPLIANCE_URL = 'https://compliance.cloudcontactai.com/api'

# Test environment URLs
TEST_BASE_URL = 'https://core-test-cloudcontactai.allcode.com/api'
TEST_EMAIL_URL = 'https://email-campaigns-test-cloudcontactai.allcode.com/api/v1'
TEST_FILES_URL = 'https://files-test-cloudcontactai.allcode.com'
TEST_BASE_URL = 'https://core-test-cloudcontactai.allcode.com/api'
TEST_EMAIL_URL = 'https://email-campaigns-test-cloudcontactai.allcode.com/api/v1'
TEST_FILES_URL = 'https://files-test-cloudcontactai.allcode.com'
TEST_COMPLIANCE_URL = 'https://compliance-test-cloudcontactai.allcode.com/api'

# Create a new configuration
#
Expand All @@ -34,7 +38,8 @@ class Config
# @param base_url [String, nil] Override base URL for the core API
# @param email_base_url [String, nil] Override base URL for the Email API
# @param files_base_url [String, nil] Override base URL for the Files API
def initialize(client_id:, api_key:, use_test_environment: false, base_url: nil, email_base_url: nil, files_base_url: nil)
# @param compliance_base_url [String, nil] Override base URL for the Compliance API
def initialize(client_id:, api_key:, use_test_environment: false, base_url: nil, email_base_url: nil, files_base_url: nil, compliance_base_url: nil)
@client_id = client_id
@api_key = api_key
@use_test_environment = use_test_environment
Expand All @@ -51,12 +56,16 @@ def initialize(client_id:, api_key:, use_test_environment: false, base_url: nil,
@files_base_url = files_base_url ||
ENV.fetch('CCAI_FILES_BASE_URL', nil) ||
(use_test_environment ? TEST_FILES_URL : PROD_FILES_URL)

@compliance_base_url = compliance_base_url ||
ENV.fetch('CCAI_COMPLIANCE_BASE_URL', nil) ||
(use_test_environment ? TEST_COMPLIANCE_URL : PROD_COMPLIANCE_URL)
end
end

# Main client for interacting with the CloudContactAI API
class Client
attr_reader :config, :sms, :mms, :email, :webhook, :contact
attr_reader :config, :sms, :mms, :email, :webhook, :contact, :brand, :campaign

# Create a new CCAI client instance
#
Expand Down Expand Up @@ -88,6 +97,12 @@ def initialize(config)

# Initialize the Contact service
@contact = Contact::ContactService.new(self)

# Initialize the Brand service
@brand = Brand::BrandService.new(self)

# Initialize the Campaign service
@campaign = Campaign::CampaignService.new(self)
end

# Get the client ID
Expand Down Expand Up @@ -125,6 +140,13 @@ def files_base_url
@config.files_base_url
end

# Get the base URL for the Compliance API
#
# @return [String] Compliance base URL
def compliance_base_url
@config.compliance_base_url
end

# Whether the test environment is active
#
# @return [Boolean]
Expand Down Expand Up @@ -155,8 +177,31 @@ def request(method, endpoint, data = nil, headers = nil)
raise Error.new("Request failed: #{e.message}")
end
end

# Make an authenticated API request to the Compliance API
#
# @param method [Symbol] HTTP method (:get, :post, etc.)
# @param endpoint [String] API endpoint
# @param data [Hash, nil] Request data
# @return [Hash] API response
# @raise [CCAI::Error] If the API returns an error
def compliance_request(method, endpoint, data = nil)
url = "#{@config.compliance_base_url}#{endpoint}"

begin
response = @connection.run_request(method, url, data ? data.to_json : nil, nil)

if response.success?
response.body.nil? || response.body.empty? ? {} : JSON.parse(response.body)
else
raise Error.new("API Error: #{response.status} - #{response.body}")
end
rescue Faraday::Error => e
raise Error.new("Request failed: #{e.message}")
end
end
end

# Base error class for CCAI errors
class Error < StandardError; end
end
end