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
37 changes: 37 additions & 0 deletions app/services/whatsapp/identifier_sync_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,48 @@ class Whatsapp::IdentifierSyncService

def perform(source_ids: [], username: nil, phone_number: nil)
create_contact_inboxes(source_ids)
group_identifiers(source_ids)
update_contact(username, phone_number)
end

private

# The identifiers carried by one webhook are the only evidence Meta gives that two source ids
# belong to the same person, and it gives it once, when the payload arrives. Recording it lets a
# call site widen its scope to that evidence rather than to the whole contact, which is what
# breaks once an agent merges two people in the dashboard.
#
# This never runs from a merge, only from an inbound payload, so rows an agent brought together
# keep whatever groups they already had.
def group_identifiers(source_ids)
rows = identifier_rows(source_ids)
return if rows.empty?

groups = rows.filter_map(&:identity_group_id).uniq
# Identifiers arriving together can prove that two existing groups are one person. Joining them
# is a deliberate path rather than a side effect of a webhook, and it is the one place where
# being wrong recreates the routing bug this exists to avoid, so this leaves them alone.
return if groups.length > 1

assign_identity_group(rows, groups.first || SecureRandom.uuid)
end

def assign_identity_group(rows, identity_group_id)
ungrouped = rows.select { |row| row.identity_group_id.nil? }
return if ungrouped.empty?

# rubocop:disable Rails/SkipsModelValidations
ContactInbox.where(id: ungrouped.map(&:id)).update_all(identity_group_id: identity_group_id, updated_at: Time.current)
# rubocop:enable Rails/SkipsModelValidations
end

def identifier_rows(source_ids)
identifiers = source_ids.compact_blank.uniq
return [] if identifiers.blank?

inbox.contact_inboxes.where(source_id: identifiers).to_a
end

def create_contact_inboxes(source_ids)
source_ids.compact_blank.uniq.each do |source_id|
next if inbox.contact_inboxes.exists?(source_id: source_id)
Expand Down
15 changes: 15 additions & 0 deletions db/migrate/20260819120000_add_identity_group_to_contact_inboxes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class AddIdentityGroupToContactInboxes < ActiveRecord::Migration[7.1]
disable_ddl_transaction!

# Records which contact inboxes one webhook proved belong to the same person. Meta answers that
# question per event and never sends a stable group identifier, so the answer only exists at the
# moment a payload arrives and Chatwoot currently has nowhere to keep it.
def change
add_column :contact_inboxes, :identity_group_id, :uuid

add_index :contact_inboxes, :identity_group_id,
name: 'index_contact_inboxes_on_identity_group_id',
algorithm: :concurrently,
if_not_exists: true
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class BackfillIdentityGroupOnContactInboxes < ActiveRecord::Migration[7.1]
# One group per existing row, which is the only assignment that guesses nothing. It degrades to
# the behaviour that predates coexistence, where one source id was one identity by construction,
# and groups then form as payloads arrive carrying identifiers together. Nothing is grouped that
# was not observed together.
def up
# rubocop:disable Rails/SkipsModelValidations
ContactInbox.where(identity_group_id: nil).in_batches(of: 1000) do |batch|
batch.update_all('identity_group_id = gen_random_uuid()')
end
# rubocop:enable Rails/SkipsModelValidations
end

def down
# no-op: rolling back the column drops the values with it
end
end
4 changes: 3 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.1].define(version: 2026_08_14_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_08_19_120100) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
Expand Down Expand Up @@ -771,7 +771,9 @@
t.datetime "updated_at", null: false
t.boolean "hmac_verified", default: false
t.string "pubsub_token"
t.uuid "identity_group_id"
t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id"
t.index ["identity_group_id"], name: "index_contact_inboxes_on_identity_group_id"
t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true
t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id"
t.index ["pubsub_token"], name: "index_contact_inboxes_on_pubsub_token", unique: true
Expand Down
83 changes: 83 additions & 0 deletions spec/services/whatsapp/identifier_sync_service_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
require 'rails_helper'

describe Whatsapp::IdentifierSyncService do
let!(:channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) }
let!(:inbox) { channel.inbox }
let(:account) { inbox.account }
let(:contact) { create(:contact, account: account) }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '5511999999999') }

def sync(source_ids)
described_class.new(contact_inbox: contact_inbox, contact: contact).perform(source_ids: source_ids)
end

def group_of(source_id)
inbox.contact_inboxes.find_by(source_id: source_id).identity_group_id
end

describe 'identity group' do
it 'puts identifiers that arrive in the same payload in one group' do
sync(['5511999999999', 'BR.1234567890'])

expect(group_of('5511999999999')).to be_present
expect(group_of('BR.1234567890')).to eq(group_of('5511999999999'))
end

it 'keeps the group of a row that already has one' do
sync(['5511999999999'])
original = group_of('5511999999999')

sync(['5511999999999', 'BR.1234567890'])

expect(group_of('5511999999999')).to eq(original)
expect(group_of('BR.1234567890')).to eq(original)
end

it 'gives a row that arrives alone a group of its own' do
other = create(:contact_inbox, contact: create(:contact, account: account), inbox: inbox, source_id: 'BR.9999999999')
sync(['5511999999999'])

expect(group_of('5511999999999')).to be_present
expect(other.reload.identity_group_id).to be_nil
end

it 'does not join two identifiers that already belong to different groups' do
create(:contact_inbox, contact: contact, inbox: inbox, source_id: 'BR.1234567890')
sync(['5511999999999'])
sync(['BR.1234567890'])
phone_group = group_of('5511999999999')
bsuid_group = group_of('BR.1234567890')

sync(['5511999999999', 'BR.1234567890'])

expect(group_of('5511999999999')).to eq(phone_group)
expect(group_of('BR.1234567890')).to eq(bsuid_group)
expect(phone_group).not_to eq(bsuid_group)
end

it 'does not group rows from another inbox that share a source id' do
other_channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
validate_provider_config: false, sync_templates: false)
elsewhere = create(:contact_inbox, contact: contact, inbox: other_channel.inbox, source_id: 'BR.1234567890')

sync(['5511999999999', 'BR.1234567890'])

expect(elsewhere.reload.identity_group_id).to be_nil
end

it 'leaves groups alone when a merge brings rows together' do
mergee = create(:contact, account: account)
merged_inbox = create(:contact_inbox, contact: mergee, inbox: inbox, source_id: 'BR.5555555555')
sync(['5511999999999'])
described_class.new(contact_inbox: merged_inbox, contact: mergee).perform(source_ids: ['BR.5555555555'])
phone_group = group_of('5511999999999')
merged_group = group_of('BR.5555555555')

ContactMergeAction.new(account: account, base_contact: contact, mergee_contact: mergee).perform

expect(group_of('5511999999999')).to eq(phone_group)
expect(group_of('BR.5555555555')).to eq(merged_group)
expect(phone_group).not_to eq(merged_group)
end
end
end
Loading