diff --git a/lib/ccai.rb b/lib/ccai.rb index 0191922..4006f38 100644 --- a/lib/ccai.rb +++ b/lib/ccai.rb @@ -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 diff --git a/lib/ccai/brand/brand_service.rb b/lib/ccai/brand/brand_service.rb new file mode 100644 index 0000000..7f2fed6 --- /dev/null +++ b/lib/ccai/brand/brand_service.rb @@ -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 diff --git a/lib/ccai/campaign/campaign_service.rb b/lib/ccai/campaign/campaign_service.rb new file mode 100644 index 0000000..d9bb845 --- /dev/null +++ b/lib/ccai/campaign/campaign_service.rb @@ -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 diff --git a/lib/ccai/client.rb b/lib/ccai/client.rb index 271876c..0e1149f 100644 --- a/lib/ccai/client.rb +++ b/lib/ccai/client.rb @@ -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 # @@ -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 @@ -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 # @@ -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 @@ -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] @@ -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 \ No newline at end of file