diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 4301eaa4a..e2b930ac9 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -110,6 +110,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def update_last_seen + # High-traffic accounts generate excessive DB writes when agents frequently switch between conversations. + # Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load. + # Always update immediately if there are unread messages to maintain accurate read/unread state. + return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any? + return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any? + + # No unread messages - apply throttling to limit DB writes + return unless should_update_last_seen? + update_last_seen_on_conversation(DateTime.now.utc, assignee?) end @@ -142,12 +151,25 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def update_last_seen_on_conversation(last_seen_at, update_assignee) + updates = { agent_last_seen_at: last_seen_at } + updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present? + # rubocop:disable Rails/SkipsModelValidations - @conversation.update_column(:agent_last_seen_at, last_seen_at) - @conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present? + @conversation.update_columns(updates) # rubocop:enable Rails/SkipsModelValidations end + def should_update_last_seen? + # Update if at least one relevant timestamp is older than 1 hour or not set + # This prevents redundant DB writes when agents repeatedly view the same conversation + agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago + return agent_needs_update unless assignee? + + # For assignees, check both timestamps - update if either is old + assignee_needs_update = @conversation.assignee_last_seen_at.blank? || @conversation.assignee_last_seen_at < 1.hour.ago + agent_needs_update || assignee_needs_update + end + def set_conversation_status @conversation.status = params[:status] @conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until] diff --git a/app/models/conversation.rb b/app/models/conversation.rb index ac0985416..ca53238e8 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -167,6 +167,10 @@ class Conversation < ApplicationRecord agent_last_seen_at.present? ? messages.created_since(agent_last_seen_at) : messages end + def assignee_unread_messages + assignee_last_seen_at.present? ? messages.created_since(assignee_last_seen_at) : messages + end + def unread_incoming_messages unread_messages.where(account_id: account_id).incoming.last(10) end diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 38bd649fe..3c380c155 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -667,6 +667,8 @@ RSpec.describe 'Conversations API', type: :request do end it 'updates last seen' do + conversation.update!(agent_last_seen_at: nil) + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen", headers: agent.create_new_auth_token, as: :json @@ -676,7 +678,7 @@ RSpec.describe 'Conversations API', type: :request do end it 'updates assignee last seen' do - conversation.update!(assignee_id: agent.id) + conversation.update!(assignee_id: agent.id, agent_last_seen_at: nil) expect(conversation.reload.assignee_last_seen_at).to be_nil @@ -687,6 +689,76 @@ RSpec.describe 'Conversations API', type: :request do expect(response).to have_http_status(:success) expect(conversation.reload.assignee_last_seen_at).not_to be_nil end + + it 'throttles updates within an hour when there are no unread messages' do + conversation.update!(agent_last_seen_at: 30.minutes.ago) + # Ensure all messages are older than agent_last_seen_at (no unread messages) + # rubocop:disable Rails/SkipsModelValidations + conversation.messages.update_all(created_at: 1.hour.ago) + # rubocop:enable Rails/SkipsModelValidations + initial_last_seen = conversation.agent_last_seen_at + + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(conversation.reload.agent_last_seen_at).to be_within(1.second).of(initial_last_seen) + end + + it 'updates even within an hour when there are unread messages' do + conversation.update!(agent_last_seen_at: 30.minutes.ago) + # Create a new message after agent_last_seen_at (unread message) + create(:message, conversation: conversation, created_at: 5.minutes.ago) + initial_last_seen = conversation.agent_last_seen_at + + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(conversation.reload.agent_last_seen_at).not_to be_within(1.second).of(initial_last_seen) + expect(conversation.reload.agent_last_seen_at).to be > initial_last_seen + end + + it 'updates both if one timestamp is old even when the other is recent' do + conversation.update!(assignee_id: agent.id, agent_last_seen_at: 2.hours.ago, assignee_last_seen_at: 30.minutes.ago) + # Ensure all messages are older than assignee_last_seen_at (no unread messages) + # rubocop:disable Rails/SkipsModelValidations + conversation.messages.update_all(created_at: 1.hour.ago) + # rubocop:enable Rails/SkipsModelValidations + + initial_agent_last_seen = conversation.agent_last_seen_at + + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + # Both should be updated because agent_last_seen_at is old + expect(conversation.reload.agent_last_seen_at).to be > initial_agent_last_seen + expect(conversation.reload.assignee_last_seen_at).to be > initial_agent_last_seen + end + + it 'throttles only when both timestamps are recent and no unread messages' do + conversation.update!(assignee_id: agent.id, agent_last_seen_at: 30.minutes.ago, assignee_last_seen_at: 30.minutes.ago) + # Ensure all messages are older (no unread messages) + # rubocop:disable Rails/SkipsModelValidations + conversation.messages.update_all(created_at: 1.hour.ago) + # rubocop:enable Rails/SkipsModelValidations + + initial_agent_last_seen = conversation.agent_last_seen_at + initial_assignee_last_seen = conversation.assignee_last_seen_at + + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + # Both should remain unchanged (throttled) + expect(conversation.reload.agent_last_seen_at).to be_within(1.second).of(initial_agent_last_seen) + expect(conversation.reload.assignee_last_seen_at).to be_within(1.second).of(initial_assignee_last_seen) + end end end