diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_settings.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_settings.rb index bdcbfc1d3..0ef648567 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_settings.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_settings.rb @@ -1,6 +1,20 @@ module Enterprise::Api::V1::AccountsSettings + def create + super + record_marketing_attribution + end + private + def record_marketing_attribution + return if current_user.present? + return if @account.blank? + + Internal::Accounts::MarketingAttributionService.new(account: @account, cookies: cookies).perform + rescue StandardError => e + ChatwootExceptionTracker.new(e).capture_exception + end + def permitted_settings_attributes super + [{ conversation_required_attributes: [] }] end diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb index d119d6345..593cea799 100644 --- a/enterprise/app/services/internal/accounts/internal_attributes_service.rb +++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb @@ -4,7 +4,7 @@ class Internal::Accounts::InternalAttributesService # List of keys that can be managed through this service # TODO: Add account_notes field in future # This field can be used to store notes about account on Chatwoot cloud - VALID_KEYS = %w[manually_managed_features].freeze + VALID_KEYS = %w[manually_managed_features marketing_attribution].freeze def initialize(account) @account = account diff --git a/enterprise/app/services/internal/accounts/marketing_attribution_service.rb b/enterprise/app/services/internal/accounts/marketing_attribution_service.rb new file mode 100644 index 000000000..08bb93823 --- /dev/null +++ b/enterprise/app/services/internal/accounts/marketing_attribution_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'base64' + +class Internal::Accounts::MarketingAttributionService + FIRST_TOUCH_COOKIE = 'cw_first_touch_attribution' + LAST_TOUCH_COOKIE = 'cw_last_touch_attribution' + FIELD_MAX_LENGTH = 500 + ALLOWED_FIELDS = %w[ + utm_source + utm_medium + utm_campaign + utm_term + utm_content + utm_id + gclid + gbraid + wbraid + dclid + fbclid + msclkid + ttclid + li_fat_id + twclid + rdt_cid + referrer + referrer_path + landing_page + source + source_type + captured_at + ].freeze + + pattr_initialize [:account!, :cookies!] + + def perform + return unless ChatwootApp.chatwoot_cloud? + + first_touch = attribution_cookie(FIRST_TOUCH_COOKIE) + last_touch = attribution_cookie(LAST_TOUCH_COOKIE) + return unless first_touch || last_touch + + existing_attribution = internal_attributes_service.get('marketing_attribution') || {} + internal_attributes_service.set( + 'marketing_attribution', + { + 'first_touch' => first_touch || existing_attribution['first_touch'], + 'last_touch' => last_touch || existing_attribution['last_touch'], + 'captured_from' => 'cookie', + 'stored_at' => Time.current.iso8601 + }.compact + ) + end + + private + + def attribution_cookie(cookie_name) + return if cookies[cookie_name].blank? + + parse_cookie(cookies[cookie_name].to_s) + end + + def parse_cookie(cookie_value) + validate_payload(JSON.parse(Base64.urlsafe_decode64(cookie_value))) + rescue JSON::ParserError, ArgumentError + nil + end + + def validate_payload(payload) + return unless payload.is_a?(Hash) + + payload.slice(*ALLOWED_FIELDS).filter_map do |key, value| + next if value.blank? || value.is_a?(Array) || value.is_a?(Hash) + + [key, value.to_s.first(FIELD_MAX_LENGTH)] + end.to_h.presence + end + + def internal_attributes_service + @internal_attributes_service ||= Internal::Accounts::InternalAttributesService.new(account) + end +end diff --git a/spec/enterprise/controllers/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts_controller_spec.rb new file mode 100644 index 000000000..00e50c0e8 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts_controller_spec.rb @@ -0,0 +1,66 @@ +require 'rails_helper' +require 'base64' + +RSpec.describe 'Enterprise Accounts API', type: :request do + describe 'POST /api/v1/accounts' do + let(:email) { Faker::Internet.email } + let(:user_full_name) { Faker::Name.name_with_middle } + let(:first_touch_cookie) { Base64.urlsafe_encode64({ source: 'reddit', source_type: 'paid_social' }.to_json, padding: false) } + let(:last_touch_cookie) { Base64.urlsafe_encode64({ source: 'github', source_type: 'referral' }.to_json, padding: false) } + let(:attribution_cookie_header) do + { + 'Cookie' => [ + "#{Internal::Accounts::MarketingAttributionService::FIRST_TOUCH_COOKIE}=#{first_touch_cookie}", + "#{Internal::Accounts::MarketingAttributionService::LAST_TOUCH_COOKIE}=#{last_touch_cookie}" + ].join('; ') + } + end + + before do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + end + + it 'records marketing attribution for unauthenticated signup requests' do + account_builder = double + account = create(:account) + user = create(:user, email: email, account: account, name: user_full_name) + + allow(AccountBuilder).to receive(:new).and_return(account_builder) + allow(account_builder).to receive(:perform).and_return([user, account]) + + with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do + post api_v1_accounts_url, + params: { + account_name: 'test', + email: email, + user: nil, + locale: nil, + user_full_name: user_full_name, + password: 'Password1!' + }, + headers: attribution_cookie_header, + as: :json + end + + attribution = account.reload.internal_attributes['marketing_attribution'] + expect(attribution['captured_from']).to eq('cookie') + expect(attribution['first_touch']).to include('source' => 'reddit', 'source_type' => 'paid_social') + expect(attribution['last_touch']).to include('source' => 'github', 'source_type' => 'referral') + end + + it 'does not record marketing attribution for authenticated add-workspace requests' do + existing_user = create(:user, password: 'Password1!') + + with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do + post api_v1_accounts_url, + params: { account_name: 'Second Account', email: existing_user.email, + user_full_name: existing_user.name, password: 'Password1!' }, + headers: existing_user.create_new_auth_token.merge(attribution_cookie_header), + as: :json + end + + account = Account.find(response.parsed_body.dig('data', 'account_id')) + expect(account.internal_attributes).not_to include('marketing_attribution') + end + end +end diff --git a/spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb b/spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb new file mode 100644 index 000000000..0046908dc --- /dev/null +++ b/spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'base64' + +RSpec.describe Internal::Accounts::MarketingAttributionService do + let(:account) { create(:account) } + let(:cookies) { {} } + + before do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + end + + it 'stores website attribution cookies on the account' do + cookies[described_class::FIRST_TOUCH_COOKIE] = encoded_cookie( + 'source' => 'reddit', + 'source_type' => 'paid_social', + 'referrer' => 'https://reddit.com', + 'referrer_path' => '/r/selfhosted/comments/123/chatwoot' + ) + cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie( + 'source' => 'github', + 'source_type' => 'referral' + ) + + described_class.new(account: account, cookies: cookies).perform + + attribution = account.reload.internal_attributes['marketing_attribution'] + expect(attribution['captured_from']).to eq('cookie') + expect(attribution['first_touch']['source']).to eq('reddit') + expect(attribution['first_touch']['referrer_path']).to eq('/r/selfhosted/comments/123/chatwoot') + expect(attribution['last_touch']['source']).to eq('github') + end + + it 'does not store attribution outside Chatwoot Cloud' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) + cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'reddit') + + described_class.new(account: account, cookies: cookies).perform + + expect(account.reload.internal_attributes).not_to include('marketing_attribution') + end + + it 'decodes base64url cookie values and preserves plus signs' do + cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie( + 'source' => 'google', + 'utm_campaign' => 'C++ launch' + ) + + described_class.new(account: account, cookies: cookies).perform + + attribution = account.reload.internal_attributes['marketing_attribution'] + expect(attribution['last_touch']['utm_campaign']).to eq('C++ launch') + end + + it 'preserves an existing touch when the matching cookie is absent' do + account.update!( + internal_attributes: { + 'marketing_attribution' => { + 'first_touch' => { 'source' => 'reddit' }, + 'last_touch' => { 'source' => 'github' } + } + } + ) + cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'google') + + described_class.new(account: account, cookies: cookies).perform + + attribution = account.reload.internal_attributes['marketing_attribution'] + expect(attribution['first_touch']['source']).to eq('reddit') + expect(attribution['last_touch']['source']).to eq('google') + end + + it 'preserves other internal attributes' do + account.update!(internal_attributes: { 'manually_managed_features' => ['inbound_emails'] }) + cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'google') + + described_class.new(account: account, cookies: cookies).perform + + expect(account.reload.internal_attributes['manually_managed_features']).to eq(['inbound_emails']) + end + + it 'ignores parsed cookies that are not populated attribution objects' do + account.update!( + internal_attributes: { + 'marketing_attribution' => { + 'first_touch' => { 'source' => 'reddit' }, + 'last_touch' => { 'source' => 'github' } + } + } + ) + cookies[described_class::FIRST_TOUCH_COOKIE] = {}.to_json + cookies[described_class::LAST_TOUCH_COOKIE] = [].to_json + + described_class.new(account: account, cookies: cookies).perform + + attribution = account.reload.internal_attributes['marketing_attribution'] + expect(attribution['first_touch']['source']).to eq('reddit') + expect(attribution['last_touch']['source']).to eq('github') + end + + it 'stores only allowlisted scalar attribution fields' do + cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie( + 'source' => 'google', + 'source_type' => 'paid_search', + 'utm_campaign' => 'spring', + 'unknown_field' => 'ignore me', + 'nested' => { 'value' => 'ignore me' }, + 'array' => ['ignore me'] + ) + + described_class.new(account: account, cookies: cookies).perform + + attribution = account.reload.internal_attributes['marketing_attribution'] + expect(attribution['last_touch']).to eq( + 'source' => 'google', + 'source_type' => 'paid_search', + 'utm_campaign' => 'spring' + ) + end + + it 'truncates oversized attribution values' do + cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie( + 'source' => 'google', + 'utm_campaign' => 'a' * 600 + ) + + described_class.new(account: account, cookies: cookies).perform + + attribution = account.reload.internal_attributes['marketing_attribution'] + expect(attribution['last_touch']['utm_campaign'].length).to eq(described_class::FIELD_MAX_LENGTH) + end + + it 'stores raw attribution values without escaping them' do + cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie( + 'source' => '', + 'utm_campaign' => 'launch & learn' + ) + + described_class.new(account: account, cookies: cookies).perform + + attribution = account.reload.internal_attributes['marketing_attribution'] + expect(attribution['last_touch']['source']).to eq('') + expect(attribution['last_touch']['utm_campaign']).to eq('launch & learn') + end + + it 'caps raw attribution values' do + cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie( + 'source' => 'google', + 'utm_campaign' => '&' * 600 + ) + + described_class.new(account: account, cookies: cookies).perform + + attribution = account.reload.internal_attributes['marketing_attribution'] + expect(attribution['last_touch']['utm_campaign'].length).to eq(described_class::FIELD_MAX_LENGTH) + end + + def encoded_cookie(payload) + Base64.urlsafe_encode64(payload.to_json, padding: false) + end +end