We are expanding Chatwoot’s automation capabilities by introducing **Conversation Workflows**, a dedicated section in settings where teams can configure rules that govern how conversations are closed and what information agents must fill before resolving. This feature helps teams enforce data consistency, collect structured resolution information, and ensure downstream reporting is accurate. Instead of having auto‑resolution buried inside Account Settings, we introduced a new sidebar item: - Auto‑resolve conversations (existing behaviour) - Required attributes on resolution (new) This groups all conversation‑closing logic into a single place. #### Required Attributes on Resolve Admins can now pick which custom conversation attributes must be filled before an agent can resolve a conversation. **How it works** - Admin selects one or more attributes from the list of existing conversation level custom attributes. - These selected attributes become mandatory during resolution. - List all the attributes configured via Required Attributes (Text, Number, Link, Date, List, Checkbox) - When an agent clicks Resolve Conversation: If attributes already have values → the conversation resolves normally. If attributes are missing → a modal appears prompting the agent to fill them. <img width="1554" height="1282" alt="CleanShot 2025-12-10 at 11 42 23@2x" src="https://github.com/user-attachments/assets/4cd5d6e1-abe8-4999-accd-d4a08913b373" /> #### Custom Attributes Integration On the Custom Attributes page, we will surfaced indicators showing how each attribute is being used. Each attribute will show badges such as: - Resolution → used in the required‑on‑resolve workflow - Pre‑chat form → already existing <img width="2390" height="1822" alt="CleanShot 2025-12-10 at 11 43 42@2x" src="https://github.com/user-attachments/assets/b92a6eb7-7f6c-40e6-bf23-6a5310f2d9c5" /> #### Admin Flow - Navigate to Settings → Conversation Workflows. - Under Required attributes on resolve, click Add Required Attribute. - Pick from the dropdown list of conversation attributes. - Save changes. Agents will now be prompted automatically whenever they resolve. <img width="2434" height="872" alt="CleanShot 2025-12-10 at 11 44 42@2x" src="https://github.com/user-attachments/assets/632fc0e5-767c-4a1c-8cf4-ffe3d058d319" /> #### NOTES - The Required Attributes on Resolve modal should only appear when values are missing. - Required attributes must block the resolution action until satisfied. - Bulk‑resolve actions should follow the same rules — any conversation missing attributes cannot be bulk‑resolved, rest will be resolved, show a notification that the resolution cannot be done. - API resolution does not respect the attributes. --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
222 lines
8.5 KiB
Ruby
222 lines
8.5 KiB
Ruby
# == Schema Information
|
|
#
|
|
# Table name: accounts
|
|
#
|
|
# id :integer not null, primary key
|
|
# auto_resolve_duration :integer
|
|
# custom_attributes :jsonb
|
|
# domain :string(100)
|
|
# feature_flags :bigint default(0), not null
|
|
# internal_attributes :jsonb not null
|
|
# limits :jsonb
|
|
# locale :integer default("en")
|
|
# name :string not null
|
|
# settings :jsonb
|
|
# status :integer default("active")
|
|
# support_email :string(100)
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_accounts_on_status (status)
|
|
#
|
|
|
|
class Account < ApplicationRecord
|
|
# used for single column multi flags
|
|
include FlagShihTzu
|
|
include Reportable
|
|
include Featurable
|
|
include CacheKeys
|
|
include CaptainFeaturable
|
|
|
|
SETTINGS_PARAMS_SCHEMA = {
|
|
'type': 'object',
|
|
'properties':
|
|
{
|
|
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
|
|
'auto_resolve_message': { 'type': %w[string null] },
|
|
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
|
|
'audio_transcriptions': { 'type': %w[boolean null] },
|
|
'auto_resolve_label': { 'type': %w[string null] },
|
|
'conversation_required_attributes': {
|
|
'type': %w[array null],
|
|
'items': { 'type': 'string' }
|
|
},
|
|
'captain_models': {
|
|
'type': %w[object null],
|
|
'properties': {
|
|
'editor': { 'type': %w[string null] },
|
|
'assistant': { 'type': %w[string null] },
|
|
'copilot': { 'type': %w[string null] },
|
|
'label_suggestion': { 'type': %w[string null] },
|
|
'audio_transcription': { 'type': %w[string null] },
|
|
'help_center_search': { 'type': %w[string null] }
|
|
},
|
|
'additionalProperties': false
|
|
},
|
|
'captain_features': {
|
|
'type': %w[object null],
|
|
'properties': {
|
|
'editor': { 'type': %w[boolean null] },
|
|
'assistant': { 'type': %w[boolean null] },
|
|
'copilot': { 'type': %w[boolean null] },
|
|
'label_suggestion': { 'type': %w[boolean null] },
|
|
'audio_transcription': { 'type': %w[boolean null] },
|
|
'help_center_search': { 'type': %w[boolean null] }
|
|
},
|
|
'additionalProperties': false
|
|
}
|
|
},
|
|
'required': [],
|
|
'additionalProperties': true
|
|
}.to_json.freeze
|
|
|
|
DEFAULT_QUERY_SETTING = {
|
|
flag_query_mode: :bit_operator,
|
|
check_for_column: false
|
|
}.freeze
|
|
|
|
validates :name, presence: true
|
|
validates :domain, length: { maximum: 100 }
|
|
validates_with JsonSchemaValidator,
|
|
schema: SETTINGS_PARAMS_SCHEMA,
|
|
attribute_resolver: ->(record) { record.settings }
|
|
|
|
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
|
|
|
|
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
|
store_accessor :settings, :captain_models, :captain_features
|
|
|
|
has_many :account_users, dependent: :destroy_async
|
|
has_many :agent_bot_inboxes, dependent: :destroy_async
|
|
has_many :agent_bots, dependent: :destroy_async
|
|
has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api'
|
|
has_many :articles, dependent: :destroy_async, class_name: '::Article'
|
|
has_many :assignment_policies, dependent: :destroy_async
|
|
has_many :automation_rules, dependent: :destroy_async
|
|
has_many :macros, dependent: :destroy_async
|
|
has_many :campaigns, dependent: :destroy_async
|
|
has_many :canned_responses, dependent: :destroy_async
|
|
has_many :categories, dependent: :destroy_async, class_name: '::Category'
|
|
has_many :contacts, dependent: :destroy_async
|
|
has_many :conversations, dependent: :destroy_async
|
|
has_many :csat_survey_responses, dependent: :destroy_async
|
|
has_many :custom_attribute_definitions, dependent: :destroy_async
|
|
has_many :custom_filters, dependent: :destroy_async
|
|
has_many :dashboard_apps, dependent: :destroy_async
|
|
has_many :data_imports, dependent: :destroy_async
|
|
has_many :email_channels, dependent: :destroy_async, class_name: '::Channel::Email'
|
|
has_many :facebook_pages, dependent: :destroy_async, class_name: '::Channel::FacebookPage'
|
|
has_many :instagram_channels, dependent: :destroy_async, class_name: '::Channel::Instagram'
|
|
has_many :tiktok_channels, dependent: :destroy_async, class_name: '::Channel::Tiktok'
|
|
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
|
|
has_many :inboxes, dependent: :destroy_async
|
|
has_many :labels, dependent: :destroy_async
|
|
has_many :line_channels, dependent: :destroy_async, class_name: '::Channel::Line'
|
|
has_many :mentions, dependent: :destroy_async
|
|
has_many :messages, dependent: :destroy_async
|
|
has_many :notes, dependent: :destroy_async
|
|
has_many :notification_settings, dependent: :destroy_async
|
|
has_many :notifications, dependent: :destroy_async
|
|
has_many :portals, dependent: :destroy_async, class_name: '::Portal'
|
|
has_many :sms_channels, dependent: :destroy_async, class_name: '::Channel::Sms'
|
|
has_many :teams, dependent: :destroy_async
|
|
has_many :telegram_channels, dependent: :destroy_async, class_name: '::Channel::Telegram'
|
|
has_many :twilio_sms, dependent: :destroy_async, class_name: '::Channel::TwilioSms'
|
|
has_many :twitter_profiles, dependent: :destroy_async, class_name: '::Channel::TwitterProfile'
|
|
has_many :users, through: :account_users
|
|
has_many :web_widgets, dependent: :destroy_async, class_name: '::Channel::WebWidget'
|
|
has_many :webhooks, dependent: :destroy_async
|
|
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
|
|
has_many :working_hours, dependent: :destroy_async
|
|
|
|
has_one_attached :contacts_export
|
|
|
|
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
|
|
enum :status, { active: 0, suspended: 1 }
|
|
|
|
scope :with_auto_resolve, -> { where("(settings ->> 'auto_resolve_after')::int IS NOT NULL") }
|
|
|
|
before_validation :validate_limit_keys
|
|
after_create_commit :notify_creation
|
|
after_destroy :remove_account_sequences
|
|
|
|
def agents
|
|
users.where(account_users: { role: :agent })
|
|
end
|
|
|
|
def administrators
|
|
users.where(account_users: { role: :administrator })
|
|
end
|
|
|
|
def all_conversation_tags
|
|
# returns array of tags
|
|
conversation_ids = conversations.pluck(:id)
|
|
ActsAsTaggableOn::Tagging.includes(:tag)
|
|
.where(context: 'labels',
|
|
taggable_type: 'Conversation',
|
|
taggable_id: conversation_ids)
|
|
.map { |tagging| tagging.tag.name }
|
|
end
|
|
|
|
def webhook_data
|
|
{
|
|
id: id,
|
|
name: name
|
|
}
|
|
end
|
|
|
|
def inbound_email_domain
|
|
domain.presence || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN',
|
|
false)
|
|
end
|
|
|
|
def support_email
|
|
super.presence || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
|
|
end
|
|
|
|
def usage_limits
|
|
{
|
|
agents: ChatwootApp.max_limit.to_i,
|
|
inboxes: ChatwootApp.max_limit.to_i
|
|
}
|
|
end
|
|
|
|
def locale_english_name
|
|
# the locale can also be something like pt_BR, en_US, fr_FR, etc.
|
|
# the format is `<locale_code>_<country_code>`
|
|
# we need to extract the language code from the locale
|
|
account_locale = locale&.split('_')&.first
|
|
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
|
|
end
|
|
|
|
private
|
|
|
|
def notify_creation
|
|
Rails.configuration.dispatcher.dispatch(ACCOUNT_CREATED, Time.zone.now, account: self)
|
|
end
|
|
|
|
trigger.after(:insert).for_each(:row) do
|
|
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
|
|
end
|
|
|
|
trigger.name('camp_dpid_before_insert').after(:insert).for_each(:row) do
|
|
"execute format('create sequence IF NOT EXISTS camp_dpid_seq_%s', NEW.id);"
|
|
end
|
|
|
|
def validate_limit_keys
|
|
# method overridden in enterprise module
|
|
end
|
|
|
|
def remove_account_sequences
|
|
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS camp_dpid_seq_#{id}")
|
|
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS conv_dpid_seq_#{id}")
|
|
end
|
|
end
|
|
|
|
Account.prepend_mod_with('Account')
|
|
Account.prepend_mod_with('Account::PlanUsageAndLimits')
|
|
Account.include_mod_with('Concerns::Account')
|
|
Account.include_mod_with('Audit::Account')
|