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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user