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:
Sojan Jose
2026-05-18 15:01:05 +05:30
committed by GitHub
parent 3253e863ed
commit 1d2f3e86dd
8 changed files with 38 additions and 2 deletions

View File

@@ -224,7 +224,6 @@
display_name: Companies
enabled: false
premium: true
chatwoot_internal: true
- name: channel_tiktok
display_name: TikTok Channel
enabled: true

View File

@@ -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

View File

@@ -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

View File

@@ -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:)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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