feat(account): persist signup attribution (#14761)
This keeps Chatwoot-side attribution persistence intentionally small and Enterprise-only. The website owns attribution capture, normalization, and source classification. Chatwoot Cloud only reads the already-shaped first-party attribution cookies during the current web signup account creation path and stores the decoded payload in internal account metadata. Self-hosted installs remain unchanged in this repo. ## What changed - Added Enterprise-only attribution persistence to the current Cloud web signup account creation path. - Stores attribution only when `ChatwootApp.chatwoot_cloud?` is true. - Reads the existing first-touch and last-touch attribution cookies. - Saves only the documented scalar attribution fields under account internal metadata. - Preserves raw attribution values and leaves escaping to display boundaries. - Bounds stored attribution values to the website field-size limit. - Keeps OSS controller code unchanged. - Keeps signup attribution request coverage in Enterprise specs. - Avoids backend attribution derivation, referrer parsing, or fallback classification. - Skips authenticated add-workspace flows so additional workspaces are not counted as signup attribution. - Does not hook unused account creation paths or OmniAuth account creation. ## How to test - `bundle exec rubocop spec/controllers/api/v1/accounts_controller_spec.rb spec/enterprise/controllers/api/v1/accounts_controller_spec.rb enterprise/app/controllers/enterprise/api/v1/accounts_settings.rb enterprise/app/services/internal/accounts/marketing_attribution_service.rb spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb` - `bundle exec rspec spec/controllers/api/v1/accounts_controller_spec.rb spec/enterprise/controllers/api/v1/accounts_controller_spec.rb spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb` - On a cloud-like setup, create an account through the current web signup path with attribution cookies and confirm account internal metadata is populated. - On a non-cloud setup or authenticated add-workspace flow, confirm account-create behavior is unchanged and no attribution is stored.
This commit is contained in:
@@ -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
|
||||
@@ -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' => '<script>alert(1)</script>',
|
||||
'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('<script>alert(1)</script>')
|
||||
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
|
||||
Reference in New Issue
Block a user