diff --git a/.circleci/config.yml b/.circleci/config.yml index 804c63857..99ac1c29a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,7 +76,7 @@ jobs: bundle install - node/install: - node-version: '23.7' + node-version: '24.13' - node/install-pnpm - node/install-packages: pkg-manager: pnpm @@ -117,7 +117,7 @@ jobs: steps: - checkout - node/install: - node-version: '23.7' + node-version: '24.13' - node/install-pnpm - node/install-packages: pkg-manager: pnpm @@ -144,11 +144,11 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 16 + parallelism: 18 steps: - checkout - node/install: - node-version: '23.7' + node-version: '24.13' - node/install-pnpm - node/install-packages: pkg-manager: pnpm @@ -218,6 +218,49 @@ jobs: source ~/.rvm/scripts/rvm bundle install + # Install and configure OpenSearch + - run: + name: Install OpenSearch + command: | + # Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients) + wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz + tar -xzf opensearch-2.11.0-linux-x64.tar.gz + sudo mv opensearch-2.11.0 /opt/opensearch + + - run: + name: Configure and Start OpenSearch + command: | + # Configure OpenSearch for single-node testing + cat > /opt/opensearch/config/opensearch.yml \<< EOF + cluster.name: chatwoot-test + node.name: node-1 + network.host: 0.0.0.0 + http.port: 9200 + discovery.type: single-node + plugins.security.disabled: true + EOF + + # Set ownership and permissions + sudo chown -R $USER:$USER /opt/opensearch + + # Start OpenSearch in background + /opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid + + - run: + name: Wait for OpenSearch to be ready + command: | + echo "Waiting for OpenSearch to start..." + for i in {1..30}; do + if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then + echo "OpenSearch is ready!" + exit 0 + fi + echo "Waiting... ($i/30)" + sleep 2 + done + echo "OpenSearch failed to start" + exit 1 + # Configure environment and database - run: name: Database Setup and Configure Environment Variables @@ -234,6 +277,7 @@ jobs: sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env echo -en "\nINSTALLATION_ENV=circleci" >> ".env" + echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env" # Database setup - run: @@ -306,12 +350,12 @@ jobs: destination: coverage build: - <<: *defaults - steps: - - run: - name: Legacy build aggregator - command: | - echo "All main jobs passed; build job kept only for GitHub required check compatibility." + <<: *defaults + steps: + - run: + name: Legacy build aggregator + command: | + echo "All main jobs passed; build job kept only for GitHub required check compatibility." workflows: version: 2 diff --git a/.devcontainer/docker-compose.base.yml b/.devcontainer/docker-compose.base.yml index 6932b5f10..375742ff7 100644 --- a/.devcontainer/docker-compose.base.yml +++ b/.devcontainer/docker-compose.base.yml @@ -10,7 +10,7 @@ services: dockerfile: .devcontainer/Dockerfile.base args: VARIANT: 'ubuntu-22.04' - NODE_VERSION: '23.7.0' + NODE_VERSION: '24.13.0' RUBY_VERSION: '3.4.4' # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. USER_UID: '1000' diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index a9185ea09..d696f99cc 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -11,7 +11,7 @@ services: dockerfile: .devcontainer/Dockerfile args: VARIANT: 'ubuntu-22.04' - NODE_VERSION: '23.7.0' + NODE_VERSION: '24.13.0' RUBY_VERSION: '3.4.4' # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. USER_UID: '1000' diff --git a/.env.example b/.env.example index d5c7a76f9..bc7380a29 100644 --- a/.env.example +++ b/.env.example @@ -274,3 +274,6 @@ AZURE_APP_SECRET= # Set to true if you want to remove stale contact inboxes # contact_inboxes with no conversation older than 90 days will be removed # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false + +# REDIS_ALFRED_SIZE=10 +# REDIS_VELMA_SIZE=10 diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 45ff25203..1d1116d0c 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 011f862b0..c2a626388 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -28,7 +28,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies run: pnpm i @@ -43,7 +43,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies run: pnpm i @@ -94,7 +94,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index c2a4bd174..7869bf89c 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: pnpm diff --git a/.nvmrc b/.nvmrc index b88575e38..cf2efde81 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -23.7.0 \ No newline at end of file +24.13.0 \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 0a669b606..b7b7301d3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -441,7 +441,8 @@ GEM http-cookie (1.0.5) domain_name (~> 0.5) http-form_data (2.3.0) - httparty (0.21.0) + httparty (0.24.0) + csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) @@ -540,9 +541,9 @@ GEM net-smtp marcel (1.0.4) maxminddb (0.1.22) - meta_request (0.8.3) + meta_request (0.8.5) rack-contrib (>= 1.1, < 3) - railties (>= 3.0.0, < 8) + railties (>= 3.0.0, < 9) method_source (1.1.0) mime-types (3.4.1) mime-types-data (~> 3.2015) @@ -555,7 +556,8 @@ GEM ruby2_keywords msgpack (1.8.0) multi_json (1.15.0) - multi_xml (0.6.0) + multi_xml (0.8.0) + bigdecimal (>= 3.1, < 5) multipart-post (2.3.0) mutex_m (0.3.0) neighbor (0.2.3) diff --git a/README.md b/README.md index 21316b422..d8b8ae7a2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ ___ The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
-
@@ -137,4 +136,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
-*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License.
+*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License.
diff --git a/VERSION_CW b/VERSION_CW
index 88f181192..ad96464c4 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.8.0
+4.10.1
diff --git a/VERSION_CWCTL b/VERSION_CWCTL
index 6cb9d3dd0..1545d9665 100644
--- a/VERSION_CWCTL
+++ b/VERSION_CWCTL
@@ -1 +1 @@
-3.4.3
+3.5.0
diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb
index 8b40ba3c9..8045e84c9 100644
--- a/app/builders/messages/instagram/base_message_builder.rb
+++ b/app/builders/messages/instagram/base_message_builder.rb
@@ -112,6 +112,25 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
+ create_story_reply_attachment(story_reply_attributes['url'])
+ end
+
+ def create_story_reply_attachment(story_url)
+ return if story_url.blank?
+
+ attachment = @message.attachments.new(
+ file_type: :ig_story,
+ account_id: @message.account_id,
+ external_url: story_url
+ )
+ attachment.save!
+ begin
+ attach_file(attachment, story_url)
+ rescue Down::Error, StandardError => e
+ Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
+ end
+ @message.content_attributes[:image_type] = 'ig_story_reply'
+ @message.save!
end
def build_conversation
@@ -139,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
+ status: @outgoing_echo ? :delivered : :sent,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
@@ -147,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
}
}
+ params[:content_attributes][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
diff --git a/app/builders/v2/reports/channel_summary_builder.rb b/app/builders/v2/reports/channel_summary_builder.rb
new file mode 100644
index 000000000..2df8fc081
--- /dev/null
+++ b/app/builders/v2/reports/channel_summary_builder.rb
@@ -0,0 +1,38 @@
+class V2::Reports::ChannelSummaryBuilder
+ include DateRangeHelper
+
+ pattr_initialize [:account!, :params!]
+
+ def build
+ conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
+ end
+
+ private
+
+ def conversations_by_channel_and_status
+ account.conversations
+ .joins(:inbox)
+ .where(created_at: range)
+ .group('inboxes.channel_type', 'conversations.status')
+ .count
+ .each_with_object({}) do |((channel_type, status), count), grouped|
+ grouped[channel_type] ||= {}
+ grouped[channel_type][status] = count
+ end
+ end
+
+ def build_channel_stats(status_counts)
+ open_count = status_counts['open'] || 0
+ resolved_count = status_counts['resolved'] || 0
+ pending_count = status_counts['pending'] || 0
+ snoozed_count = status_counts['snoozed'] || 0
+
+ {
+ open: open_count,
+ resolved: resolved_count,
+ pending: pending_count,
+ snoozed: snoozed_count,
+ total: open_count + resolved_count + pending_count + snoozed_count
+ }
+ end
+end
diff --git a/app/builders/v2/reports/first_response_time_distribution_builder.rb b/app/builders/v2/reports/first_response_time_distribution_builder.rb
new file mode 100644
index 000000000..971542596
--- /dev/null
+++ b/app/builders/v2/reports/first_response_time_distribution_builder.rb
@@ -0,0 +1,68 @@
+class V2::Reports::FirstResponseTimeDistributionBuilder
+ include DateRangeHelper
+
+ attr_reader :account, :params
+
+ def initialize(account:, params:)
+ @account = account
+ @params = params
+ end
+
+ def build
+ build_distribution
+ end
+
+ private
+
+ def build_distribution
+ results = fetch_aggregated_counts
+ map_to_channel_types(results)
+ end
+
+ def fetch_aggregated_counts
+ ReportingEvent
+ .where(account_id: account.id, name: 'first_response')
+ .where(range_condition)
+ .group(:inbox_id)
+ .select(
+ :inbox_id,
+ bucket_case_statements
+ )
+ end
+
+ def bucket_case_statements
+ <<~SQL.squish
+ COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
+ COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
+ COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
+ COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
+ COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
+ SQL
+ end
+
+ def range_condition
+ range.present? ? { created_at: range } : {}
+ end
+
+ def inbox_channel_types
+ @inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
+ end
+
+ def map_to_channel_types(results)
+ results.each_with_object({}) do |row, hash|
+ channel_type = inbox_channel_types[row.inbox_id]
+ next unless channel_type
+
+ hash[channel_type] ||= empty_buckets
+ hash[channel_type]['0-1h'] += row.bucket_0_1h
+ hash[channel_type]['1-4h'] += row.bucket_1_4h
+ hash[channel_type]['4-8h'] += row.bucket_4_8h
+ hash[channel_type]['8-24h'] += row.bucket_8_24h
+ hash[channel_type]['24h+'] += row.bucket_24h_plus
+ end
+ end
+
+ def empty_buckets
+ { '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
+ end
+end
diff --git a/app/builders/v2/reports/inbox_label_matrix_builder.rb b/app/builders/v2/reports/inbox_label_matrix_builder.rb
new file mode 100644
index 000000000..c3715019d
--- /dev/null
+++ b/app/builders/v2/reports/inbox_label_matrix_builder.rb
@@ -0,0 +1,65 @@
+class V2::Reports::InboxLabelMatrixBuilder
+ include DateRangeHelper
+
+ attr_reader :account, :params
+
+ def initialize(account:, params:)
+ @account = account
+ @params = params
+ end
+
+ def build
+ {
+ inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
+ labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
+ matrix: build_matrix
+ }
+ end
+
+ private
+
+ def filtered_inboxes
+ @filtered_inboxes ||= begin
+ inboxes = account.inboxes
+ inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
+ inboxes.order(:name).to_a
+ end
+ end
+
+ def filtered_labels
+ @filtered_labels ||= begin
+ labels = account.labels
+ labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
+ labels.order(:title).to_a
+ end
+ end
+
+ def conversation_filter
+ filter = { account_id: account.id }
+ filter[:created_at] = range if range.present?
+ filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
+ filter
+ end
+
+ def fetch_grouped_counts
+ label_names = filtered_labels.map(&:title)
+ return {} if label_names.empty?
+
+ ActsAsTaggableOn::Tagging
+ .joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
+ .joins('INNER JOIN tags ON taggings.tag_id = tags.id')
+ .where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
+ .where(tags: { name: label_names })
+ .group('conversations.inbox_id', 'tags.name')
+ .count
+ end
+
+ def build_matrix
+ counts = fetch_grouped_counts
+ filtered_inboxes.map do |inbox|
+ filtered_labels.map do |label|
+ counts[[inbox.id, label.title]] || 0
+ end
+ end
+ end
+end
diff --git a/app/builders/v2/reports/outgoing_messages_count_builder.rb b/app/builders/v2/reports/outgoing_messages_count_builder.rb
new file mode 100644
index 000000000..ac0de59f2
--- /dev/null
+++ b/app/builders/v2/reports/outgoing_messages_count_builder.rb
@@ -0,0 +1,79 @@
+class V2::Reports::OutgoingMessagesCountBuilder
+ include DateRangeHelper
+ attr_reader :account, :params
+
+ def initialize(account, params)
+ @account = account
+ @params = params
+ end
+
+ def build
+ send("build_by_#{params[:group_by]}")
+ end
+
+ private
+
+ def base_messages
+ account.messages.outgoing.unscope(:order).where(created_at: range)
+ end
+
+ def build_by_agent
+ counts = base_messages
+ .where(sender_type: 'User')
+ .where.not(sender_id: nil)
+ .group(:sender_id)
+ .count
+
+ user_names = account.users.where(id: counts.keys).index_by(&:id)
+
+ counts.map do |user_id, count|
+ user = user_names[user_id]
+ { id: user_id, name: user&.name, outgoing_messages_count: count }
+ end
+ end
+
+ def build_by_team
+ counts = base_messages
+ .joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
+ .where.not(conversations: { team_id: nil })
+ .group('conversations.team_id')
+ .count
+
+ team_names = account.teams.where(id: counts.keys).index_by(&:id)
+
+ counts.map do |team_id, count|
+ team = team_names[team_id]
+ { id: team_id, name: team&.name, outgoing_messages_count: count }
+ end
+ end
+
+ def build_by_inbox
+ counts = base_messages
+ .group(:inbox_id)
+ .count
+
+ inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
+
+ counts.map do |inbox_id, count|
+ inbox = inbox_names[inbox_id]
+ { id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
+ end
+ end
+
+ def build_by_label
+ counts = base_messages
+ .joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
+ .joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
+ AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
+ .joins('INNER JOIN tags ON tags.id = taggings.tag_id')
+ .group('tags.name')
+ .count
+
+ label_ids = account.labels.where(title: counts.keys).index_by(&:title)
+
+ counts.map do |label_name, count|
+ label = label_ids[label_name]
+ { id: label&.id, name: label_name, outgoing_messages_count: count }
+ end
+ end
+end
diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
new file mode 100644
index 000000000..156c031fa
--- /dev/null
+++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
@@ -0,0 +1,76 @@
+class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
+ before_action :current_account
+ before_action :authorize_account_update, only: [:update]
+
+ def show
+ render json: preferences_payload
+ end
+
+ def update
+ params_to_update = captain_params
+ @current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
+ @current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
+ @current_account.save!
+
+ render json: preferences_payload
+ end
+
+ private
+
+ def preferences_payload
+ {
+ providers: Llm::Models.providers,
+ models: Llm::Models.models,
+ features: features_with_account_preferences
+ }
+ end
+
+ def authorize_account_update
+ authorize @current_account, :update?
+ end
+
+ def captain_params
+ permitted = {}
+ permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
+ permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
+ permitted
+ end
+
+ def merged_captain_models
+ existing_models = @current_account.captain_models || {}
+ existing_models.merge(permitted_captain_models)
+ end
+
+ def merged_captain_features
+ existing_features = @current_account.captain_features || {}
+ existing_features.merge(permitted_captain_features)
+ end
+
+ def permitted_captain_models
+ params.require(:captain_models).permit(
+ :editor, :assistant, :copilot, :label_suggestion,
+ :audio_transcription, :help_center_search
+ ).to_h.stringify_keys
+ end
+
+ def permitted_captain_features
+ params.require(:captain_features).permit(
+ :editor, :assistant, :copilot, :label_suggestion,
+ :audio_transcription, :help_center_search
+ ).to_h.stringify_keys
+ end
+
+ def features_with_account_preferences
+ preferences = Current.account.captain_preferences
+ account_features = preferences[:features] || {}
+ account_models = preferences[:models] || {}
+
+ Llm::Models.feature_keys.index_with do |feature_key|
+ config = Llm::Models.feature_config(feature_key)
+ config.merge(
+ enabled: account_features[feature_key] == true,
+ selected: account_models[feature_key] || config[:default]
+ )
+ end
+ end
+end
diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb
index e6270c807..14d4f2c89 100644
--- a/app/controllers/api/v1/accounts/contacts_controller.rb
+++ b/app/controllers/api/v1/accounts/contacts_controller.rb
@@ -24,13 +24,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def search
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
- contacts = resolved_contacts.where(
- 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
- OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
+ contacts = Current.account.contacts.where(
+ 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q].strip}%"
)
- @contacts = fetch_contacts(contacts)
- @contacts_count = @contacts.total_count
+ @contacts = fetch_contacts_with_has_more(contacts)
end
def import
@@ -143,6 +141,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
.per(RESULTS_PER_PAGE)
end
+ def fetch_contacts_with_has_more(contacts)
+ includes_hash = { avatar_attachment: [:blob] }
+ includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
+
+ # Calculate offset manually to fetch one extra record for has_more check
+ offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
+ results = filtrate(contacts)
+ .includes(includes_hash)
+ .offset(offset)
+ .limit(RESULTS_PER_PAGE + 1)
+ .to_a
+
+ @has_more = results.size > RESULTS_PER_PAGE
+ results = results.first(RESULTS_PER_PAGE) if @has_more
+ @contacts_count = results.size
+ results
+ end
+
def build_contact_inbox
return if params[:inbox_id].blank?
diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb
index 4301eaa4a..b3151c8fa 100644
--- a/app/controllers/api/v1/accounts/conversations_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations_controller.rb
@@ -70,8 +70,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def transcript
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
+ return head :too_many_requests unless @conversation.account.within_email_rate_limit?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
+ @conversation.account.increment_email_sent_count
head :ok
end
@@ -110,6 +112,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 +153,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/controllers/api/v1/accounts/csat_survey_responses_controller.rb b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb
index f5bed6c34..0cde5f5c1 100644
--- a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb
+++ b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb
@@ -50,3 +50,5 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@current_page = params[:page] || 1
end
end
+
+Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
index d17fe35fb..bb5dab680 100644
--- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
+++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
@@ -1,38 +1,27 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
- DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
- DEFAULT_LANGUAGE = 'en'.freeze
-
before_action :fetch_inbox
before_action :validate_whatsapp_channel
def show
- template = @inbox.csat_config&.dig('template')
- return render json: { template_exists: false } unless template
+ service = CsatTemplateManagementService.new(@inbox)
+ result = service.template_status
- template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
- status_result = @inbox.channel.provider_service.get_template_status(template_name)
-
- render_template_status_response(status_result, template_name)
- rescue StandardError => e
- Rails.logger.error "Error fetching CSAT template status: #{e.message}"
- render json: { error: e.message }, status: :internal_server_error
+ if result[:service_error]
+ render json: { error: result[:service_error] }, status: :internal_server_error
+ else
+ render json: result
+ end
end
def create
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
- # Delete existing template even though we are using a new one.
- # We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
- delete_existing_template_if_needed
-
- result = create_template_via_provider(template_params)
+ service = CsatTemplateManagementService.new(@inbox)
+ result = service.create_template(template_params)
render_template_creation_result(result)
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
- rescue StandardError => e
- Rails.logger.error "Error creating CSAT template: #{e.message}"
- render json: { error: 'Template creation failed' }, status: :internal_server_error
end
private
@@ -43,9 +32,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
end
def validate_whatsapp_channel
- return if @inbox.whatsapp?
+ return if @inbox.whatsapp? || @inbox.twilio_whatsapp?
- render json: { error: 'CSAT template operations only available for WhatsApp channels' },
+ render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' },
status: :bad_request
end
@@ -57,35 +46,36 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
- def create_template_via_provider(template_params)
- template_config = {
- message: template_params[:message],
- button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
- base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
- language: template_params[:language] || DEFAULT_LANGUAGE,
- template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
- }
-
- @inbox.channel.provider_service.create_csat_template(template_config)
- end
-
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
+ elsif result[:service_error]
+ render json: { error: result[:service_error] }, status: :internal_server_error
else
render_failed_template_creation(result)
end
end
def render_successful_template_creation(result)
- render json: {
- template: {
- name: result[:template_name],
- template_id: result[:template_id],
- status: 'PENDING',
- language: result[:language] || DEFAULT_LANGUAGE
- }
- }, status: :created
+ if @inbox.twilio_whatsapp?
+ render json: {
+ template: {
+ friendly_name: result[:friendly_name],
+ content_sid: result[:content_sid],
+ status: result[:status] || 'pending',
+ language: result[:language] || 'en'
+ }
+ }, status: :created
+ else
+ render json: {
+ template: {
+ name: result[:template_name],
+ template_id: result[:template_id],
+ status: 'PENDING',
+ language: result[:language] || 'en'
+ }
+ }, status: :created
+ end
end
def render_failed_template_creation(result)
@@ -98,45 +88,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
}, status: :unprocessable_entity
end
- def delete_existing_template_if_needed
- template = @inbox.csat_config&.dig('template')
- return true if template.blank?
-
- template_name = template['name']
- return true if template_name.blank?
-
- template_status = @inbox.channel.provider_service.get_template_status(template_name)
- return true unless template_status[:success]
-
- deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
- if deletion_result[:success]
- Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
- true
- else
- Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
- false
- end
- rescue StandardError => e
- Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
- false
- end
-
- def render_template_status_response(status_result, template_name)
- if status_result[:success]
- render json: {
- template_exists: true,
- template_name: template_name,
- status: status_result[:template][:status],
- template_id: status_result[:template][:id]
- }
- else
- render json: {
- template_exists: false,
- error: 'Template not found'
- }
- end
- end
-
def parse_whatsapp_error(response_body)
return { user_message: nil, technical_details: nil } if response_body.blank?
diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index 1c8845c04..322c7c7fe 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
- template: [:name, :template_id, :created_at, :language] }] }]
+ template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
end
def permitted_params(channel_attributes = [])
diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb
index 13e3a6a6c..7ee25e02e 100644
--- a/app/controllers/api/v1/accounts/search_controller.rb
+++ b/app/controllers/api/v1/accounts/search_controller.rb
@@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
search_type: search_type,
params: params
).perform
+ rescue ArgumentError => e
+ render json: { error: e.message }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 57062a5b2..bcbf80355 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -92,8 +92,11 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
- params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
- conversation_required_attributes: [])
+ params.permit(*permitted_settings_attributes)
+ end
+
+ def permitted_settings_attributes
+ [:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
end
def check_signup_enabled
@@ -112,3 +115,5 @@ class Api::V1::AccountsController < Api::BaseController
}
end
end
+
+Api::V1::AccountsController.prepend_mod_with('Api::V1::AccountsSettings')
diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb
index fe5facc1a..96c15fde2 100644
--- a/app/controllers/api/v1/widget/conversations_controller.rb
+++ b/app/controllers/api/v1/widget/conversations_controller.rb
@@ -35,12 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def transcript
- if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
- ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
- conversation,
- conversation.contact.email
- )&.deliver_later
- end
+ return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
+
+ send_transcript_email
head :ok
end
@@ -77,6 +74,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
private
+ def send_transcript_email
+ return if conversation.contact&.email.blank?
+
+ ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
+ conversation,
+ conversation.contact.email
+ )&.deliver_later
+ conversation.account.increment_email_sent_count
+ end
+
def trigger_typing_event(event)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
end
diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb
index 6e2d0ff4c..192b3619c 100644
--- a/app/controllers/api/v2/accounts/reports_controller.rb
+++ b/app/controllers/api/v2/accounts/reports_controller.rb
@@ -38,6 +38,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
end
+ def conversations_summary
+ @report_data = generate_conversations_report
+ generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
+ end
+
def conversation_traffic
@report_data = generate_conversations_heatmap_report
timezone_offset = (params[:timezone_offset] || 0).to_f
@@ -57,6 +62,31 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: bot_metrics
end
+ def inbox_label_matrix
+ builder = V2::Reports::InboxLabelMatrixBuilder.new(
+ account: Current.account,
+ params: inbox_label_matrix_params
+ )
+ render json: builder.build
+ end
+
+ def first_response_time_distribution
+ builder = V2::Reports::FirstResponseTimeDistributionBuilder.new(
+ account: Current.account,
+ params: first_response_time_distribution_params
+ )
+ render json: builder.build
+ end
+
+ OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze
+
+ def outgoing_messages_count
+ return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by])
+
+ builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params)
+ render json: builder.build
+ end
+
private
def generate_csv(filename, template)
@@ -134,4 +164,28 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
+
+ def inbox_label_matrix_params
+ {
+ since: params[:since],
+ until: params[:until],
+ inbox_ids: params[:inbox_ids],
+ label_ids: params[:label_ids]
+ }
+ end
+
+ def first_response_time_distribution_params
+ {
+ since: params[:since],
+ until: params[:until]
+ }
+ end
+
+ def outgoing_messages_count_params
+ {
+ group_by: params[:group_by],
+ since: params[:since],
+ until: params[:until]
+ }
+ end
end
diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb
index f31a53c7e..98b3f05d7 100644
--- a/app/controllers/api/v2/accounts/summary_reports_controller.rb
+++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
- before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
+ before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::LabelSummaryBuilder)
end
+ def channel
+ return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
+
+ render_report_with(V2::Reports::ChannelSummaryBuilder)
+ end
+
private
def check_authorization
@@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def permitted_params
params.permit(:since, :until, :business_hours)
end
+
+ def date_range_too_long?
+ return false if permitted_params[:since].blank? || permitted_params[:until].blank?
+
+ since_time = Time.zone.at(permitted_params[:since].to_i)
+ until_time = Time.zone.at(permitted_params[:until].to_i)
+ (until_time - since_time) > 6.months
+ end
end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index abf42517c..d57ad0e53 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -16,7 +16,7 @@ class DashboardController < ActionController::Base
CHATWOOT_INBOX_TOKEN
API_CHANNEL_NAME
API_CHANNEL_THUMBNAIL
- ANALYTICS_TOKEN
+ CLOUD_ANALYTICS_TOKEN
DIRECT_UPLOADS_ENABLED
MAXIMUM_FILE_UPLOAD_SIZE
HCAPTCHA_SITE_KEY
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
new file mode 100644
index 000000000..fdf969a39
--- /dev/null
+++ b/app/controllers/health_controller.rb
@@ -0,0 +1,7 @@
+# Inherits from ActionController::Base to skip all middleware,
+# authentication, and callbacks. Used for health checks
+class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
+ def show
+ render json: { status: 'woot' }
+ end
+end
diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb
index ec51305b5..67d58aef1 100644
--- a/app/controllers/super_admin/app_configs_controller.rb
+++ b/app/controllers/super_admin/app_configs_controller.rb
@@ -42,19 +42,20 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
- 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
+ 'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
- 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
+ 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
- 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
+ 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
+ 'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT]
}
@allowed_configs = mapping.fetch(
@config,
- %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
+ %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
)
end
end
diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb
index 23694d08d..1f34d7e97 100644
--- a/app/helpers/api/v2/accounts/reports_helper.rb
+++ b/app/helpers/api/v2/accounts/reports_helper.rb
@@ -46,6 +46,13 @@ module Api::V2::Accounts::ReportsHelper
end
end
+ def generate_conversations_report
+ builder = V2::Reports::Conversations::MetricBuilder.new(Current.account, build_params(type: :account))
+ summary = builder.summary
+
+ [generate_conversation_report_metrics(summary)]
+ end
+
private
def build_params(base_params)
@@ -71,4 +78,16 @@ module Api::V2::Accounts::ReportsHelper
report[:resolved_conversations_count]
]
end
+
+ def generate_conversation_report_metrics(summary)
+ [
+ summary[:conversations_count],
+ summary[:incoming_messages_count],
+ summary[:outgoing_messages_count],
+ Reports::TimeFormatPresenter.new(summary[:avg_first_response_time]).format,
+ Reports::TimeFormatPresenter.new(summary[:avg_resolution_time]).format,
+ summary[:resolutions_count],
+ Reports::TimeFormatPresenter.new(summary[:reply_time]).format
+ ]
+ end
end
diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml
index 34c7a8138..f21a97f78 100644
--- a/app/helpers/super_admin/features.yml
+++ b/app/helpers/super_admin/features.yml
@@ -2,13 +2,6 @@
# No need to replicate the same values in two places
# ------- Premium Features ------- #
-captain:
- name: 'Captain'
- description: 'Enable AI-powered conversations with your customers.'
- enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
- icon: 'icon-captain'
- config_key: 'captain'
- enterprise: true
saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
@@ -48,6 +41,12 @@ help_center:
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
+captain:
+ name: 'Captain'
+ description: 'Enable AI-powered conversations with your customers.'
+ enabled: true
+ icon: 'icon-captain'
+ config_key: 'captain'
# ------- Communication Channels ------- #
live_chat:
diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue
index a63cb1a90..61d7631e7 100644
--- a/app/javascript/dashboard/App.vue
+++ b/app/javascript/dashboard/App.vue
@@ -131,7 +131,7 @@ export default {