feat(companies): track company last activity (#14435)
Tracks company recency from linked contact activity so the Companies list and detail page can show/sort by real customer engagement instead of generic record updates. ## Closes None. ## Why Company recency should reflect activity from people associated with the company. This keeps the signal tied to persisted contact activity, without treating passive online presence or widget heartbeat pings as company activity. ## What Changed - Adds a company helper to record `last_activity_at` from linked contact activity. - Rolls up `Contact#last_activity_at` changes to the associated company. - Initializes company activity when an already-active contact is associated with a company, including the business-email auto-association path. - Throttles company activity rollups to once every 5 minutes per company to avoid unnecessary writes during active conversations. - Treats company activity as monotonic: unlinking, moving, or deleting contacts does not move a company's activity timestamp backwards. - Leaves historical backfill, online presence tracking, widget visit tracking, and richer activity attribution out of scope. ## How to Test 1. Open an account with Companies enabled and visit the Companies list. 2. Trigger activity for a contact that belongs to a company, for example by receiving or sending a message in that contact's conversation. 3. Confirm the linked company shows a recent activity timestamp in the Companies list/detail page after the contact activity updates. 4. Associate an already-active contact with a company and confirm the company receives that contact's existing activity timestamp. 5. Confirm repeated contact activity within a short window does not continuously rewrite the company timestamp. --------- Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
This commit is contained in:
@@ -224,7 +224,6 @@
|
||||
display_name: Companies
|
||||
enabled: false
|
||||
premium: true
|
||||
chatwoot_internal: true
|
||||
- name: channel_tiktok
|
||||
display_name: TikTok Channel
|
||||
enabled: true
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
class Company < ApplicationRecord
|
||||
include Avatarable
|
||||
|
||||
ACTIVITY_ROLLUP_INTERVAL = 5.minutes
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT }
|
||||
validates :domain, allow_blank: true, format: {
|
||||
@@ -58,6 +60,12 @@ class Company < ApplicationRecord
|
||||
)
|
||||
}
|
||||
|
||||
def record_activity_at!(activity_at)
|
||||
return if last_activity_at.present? && last_activity_at > activity_at - ACTIVITY_ROLLUP_INTERVAL
|
||||
|
||||
update!(last_activity_at: activity_at)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepare_jsonb_attributes
|
||||
|
||||
@@ -6,6 +6,7 @@ module Enterprise::Concerns::Contact
|
||||
after_commit :associate_company_from_email,
|
||||
on: [:create, :update],
|
||||
if: :should_associate_company?
|
||||
after_update_commit :record_company_activity, if: :saved_change_to_last_activity_at?
|
||||
end
|
||||
|
||||
private
|
||||
@@ -28,4 +29,8 @@ module Enterprise::Concerns::Contact
|
||||
Rails.logger.error("Failed to associate company for contact #{id}: #{e.message}")
|
||||
# Don't fail the contact save if the company association fails
|
||||
end
|
||||
|
||||
def record_company_activity
|
||||
company&.record_activity_at!(last_activity_at) if last_activity_at.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,6 +7,7 @@ class Companies::ContactMembershipService
|
||||
|
||||
def assign(contact:)
|
||||
contact.update!(company: company)
|
||||
company.record_activity_at!(contact.last_activity_at) if contact.last_activity_at.present?
|
||||
end
|
||||
|
||||
def remove(contact:)
|
||||
|
||||
@@ -9,6 +9,7 @@ class Contacts::CompanyAssociationService
|
||||
contact.update_column(:company_id, company.id)
|
||||
Company.increment_counter(:contacts_count, company.id)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
company.record_activity_at!(contact.last_activity_at) if contact.last_activity_at.present?
|
||||
end
|
||||
company
|
||||
end
|
||||
|
||||
@@ -46,7 +46,8 @@ RSpec.describe 'Company contacts API', type: :request do
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/companies/{company.id}/contacts' do
|
||||
it 'links an existing contact to the company' do
|
||||
contact = create(:contact, name: 'Jane Contact', account: account, additional_attributes: { 'city' => 'Berlin' })
|
||||
contact = create(:contact, name: 'Jane Contact', account: account, last_activity_at: 1.hour.ago,
|
||||
additional_attributes: { 'city' => 'Berlin' })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/companies/#{company.id}/contacts",
|
||||
params: { contact_id: contact.id },
|
||||
@@ -58,6 +59,7 @@ RSpec.describe 'Company contacts API', type: :request do
|
||||
expect(contact.additional_attributes).to eq('city' => 'Berlin')
|
||||
expect(response.parsed_body['payload']['company_id']).to eq(company.id)
|
||||
expect(response.parsed_body['payload']['linked_to_current_company']).to be true
|
||||
expect(company.reload.last_activity_at).to be_within(1.second).of(contact.last_activity_at)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -35,4 +35,15 @@ RSpec.describe Company, type: :model do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#record_activity_at!' do
|
||||
it 'does not move company activity backwards' do
|
||||
company = create(:company, last_activity_at: Time.zone.now)
|
||||
original_activity_at = company.last_activity_at
|
||||
|
||||
company.record_activity_at!(1.hour.ago)
|
||||
|
||||
expect(company.reload.last_activity_at).to be_within(1.second).of(original_activity_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,6 +43,15 @@ RSpec.describe Contact, type: :model do
|
||||
contact.reload
|
||||
expect(contact.company).to eq(existing_company)
|
||||
end
|
||||
|
||||
it 'updates company activity when contact activity changes' do
|
||||
company = create(:company, account: account)
|
||||
contact = create(:contact, account: account, company: company)
|
||||
|
||||
contact.update!(last_activity_at: Time.zone.now)
|
||||
|
||||
expect(company.reload.last_activity_at).to be_within(1.second).of(contact.last_activity_at)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple contacts share the same domain' do
|
||||
|
||||
Reference in New Issue
Block a user