Files
7bf76057c2 fix: SLA handling for blocked contacts (#14861)
# Pull Request Template

## Description

Blocked contacts are now excluded from SLA assignment, processing,
reports, and conversation SLA UI while they remain blocked. Existing SLA
records are preserved, and SLA behavior resumes if the contact is
unblocked.

Fixes
https://linear.app/chatwoot/issue/CW-7435/sla-should-not-trigger-for-blocked-contacts

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- `bundle exec rspec spec/enterprise/models/conversation_spec.rb
spec/enterprise/models/applied_sla_spec.rb
spec/enterprise/services/enterprise/action_service_spec.rb
spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb
spec/enterprise/jobs/sla/process_account_applied_slas_job_spec.rb
spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/presenters/conversations/event_data_presenter_spec.rb` —
78 examples, 0 failures
- `bundle exec rubocop
enterprise/app/controllers/api/v1/accounts/applied_slas_controller.rb
enterprise/app/jobs/sla/process_account_applied_slas_job.rb
enterprise/app/models/applied_sla.rb
enterprise/app/models/enterprise/concerns/conversation.rb
enterprise/app/presenters/enterprise/conversations/event_data_presenter.rb
enterprise/app/services/enterprise/action_service.rb
enterprise/app/services/sla/evaluate_applied_sla_service.rb
lib/tasks/apply_sla.rake
spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb
spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/controllers/enterprise/api/v1/accounts/conversations_controller_spec.rb
spec/enterprise/jobs/sla/process_account_applied_slas_job_spec.rb
spec/enterprise/models/applied_sla_spec.rb
spec/enterprise/models/conversation_spec.rb
spec/enterprise/presenters/conversations/event_data_presenter_spec.rb
spec/enterprise/services/enterprise/action_service_spec.rb
spec/enterprise/services/sla/evaluate_applied_sla_service_spec.rb` — no
offenses
- `pnpm exec vitest --no-watch --no-cache --no-coverage
app/javascript/dashboard/components/widgets/conversation/specs/ConversationCard.spec.js`
— 2 tests passed
- `pnpm exec eslint
app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue
app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue
app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue
app/javascript/dashboard/components/widgets/conversation/specs/ConversationCard.spec.js`
— passed with existing raw-text warnings in `ConversationHeader.vue`
- `git diff --cached --check` — clean

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-02 14:16:29 +05:30

136 lines
5.1 KiB
Ruby

# == Schema Information
#
# Table name: applied_slas
#
# id :bigint not null, primary key
# sla_status :integer default("active")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# conversation_id :bigint not null
# sla_policy_id :bigint not null
#
# Indexes
#
# index_applied_slas_on_account_id (account_id)
# index_applied_slas_on_account_sla_policy_conversation (account_id,sla_policy_id,conversation_id) UNIQUE
# index_applied_slas_on_conversation_id (conversation_id)
# index_applied_slas_on_sla_policy_id (sla_policy_id)
#
class AppliedSla < ApplicationRecord
belongs_to :account
belongs_to :sla_policy
belongs_to :conversation
has_many :sla_events, dependent: :destroy_async
validates :account_id, uniqueness: { scope: %i[sla_policy_id conversation_id] }
before_validation :ensure_account_id
enum sla_status: { active: 0, hit: 1, missed: 2, active_with_misses: 3 }
scope :filter_by_date_range, ->(range) { where(created_at: range) if range.present? }
scope :filter_by_inbox_id, ->(inbox_id) { joins(:conversation).where(conversations: { inbox_id: inbox_id }) if inbox_id.present? }
scope :filter_by_team_id, ->(team_id) { joins(:conversation).where(conversations: { team_id: team_id }) if team_id.present? }
scope :filter_by_sla_policy_id, ->(sla_policy_id) { where(sla_policy_id: sla_policy_id) if sla_policy_id.present? }
scope :filter_by_label_list, lambda { |label_list|
joins(:conversation).where('conversations.cached_label_list LIKE ?', "%#{label_list}%") if label_list.present?
}
scope :filter_by_assigned_agent_id, lambda { |assigned_agent_id|
joins(:conversation).where(conversations: { assignee_id: assigned_agent_id }) if assigned_agent_id.present?
}
scope :missed, -> { where(sla_status: %i[missed active_with_misses]) }
scope :with_sla_applicable_conversation, -> { where(conversation_id: Conversation.with_sla_applicable_contact.select(:id)) }
after_update_commit :push_conversation_event
def push_event_data
sla_due_at_values = due_at_values
{
id: id,
sla_id: sla_policy_id,
sla_status: sla_status,
created_at: created_at.to_i,
updated_at: updated_at.to_i,
sla_description: sla_policy.description,
sla_name: sla_policy.name,
sla_first_response_time_threshold: sla_policy.first_response_time_threshold,
sla_next_response_time_threshold: sla_policy.next_response_time_threshold,
sla_only_during_business_hours: sla_policy.only_during_business_hours,
sla_resolution_time_threshold: sla_policy.resolution_time_threshold,
sla_frt_due_at: sla_due_at_values[:frt],
sla_nrt_due_at: sla_due_at_values[:nrt],
sla_rt_due_at: sla_due_at_values[:rt]
}
end
def due_at_values
working_hours_by_day_cache = conversation.inbox.working_hours.index_by(&:day_of_week) if sla_policy.only_during_business_hours?
{
frt: frt_due_at(working_hours_by_day_cache: working_hours_by_day_cache),
nrt: nrt_due_at(working_hours_by_day_cache: working_hours_by_day_cache),
rt: rt_due_at(working_hours_by_day_cache: working_hours_by_day_cache)
}
end
def frt_due_at(working_hours_by_day_cache: nil)
return nil if sla_policy.first_response_time_threshold.blank?
calculate_due_at(
conversation.created_at,
sla_policy.first_response_time_threshold,
working_hours_by_day_cache: working_hours_by_day_cache
)
end
def nrt_due_at(working_hours_by_day_cache: nil)
return nil if sla_policy.next_response_time_threshold.blank?
return nil if conversation.waiting_since.blank?
calculate_due_at(
conversation.waiting_since,
sla_policy.next_response_time_threshold,
working_hours_by_day_cache: working_hours_by_day_cache
)
end
def rt_due_at(working_hours_by_day_cache: nil)
return nil if sla_policy.resolution_time_threshold.blank?
calculate_due_at(
conversation.created_at,
sla_policy.resolution_time_threshold,
working_hours_by_day_cache: working_hours_by_day_cache
)
end
def calculate_due_at(start_time, threshold_seconds, working_hours_by_day_cache: nil)
return (start_time + threshold_seconds.to_i.seconds).to_i unless sla_policy.only_during_business_hours?
Sla::BusinessHoursService.new(
inbox: conversation.inbox,
start_time: start_time,
threshold_seconds: threshold_seconds,
working_hours_by_day_cache: working_hours_by_day_cache
).deadline.to_i
end
private
def push_conversation_event
# right now we simply use `CONVERSATION_UPDATED` event to notify the frontend
# we can eventually start using `CONVERSATION_SLA_UPDATED` event as required later
# for now the updated event should suffice
return unless saved_change_to_sla_status?
conversation.dispatch_conversation_updated_event
end
def ensure_account_id
self.account_id ||= sla_policy&.account_id
end
end