From 6b767dbe94fd0346085a41e92fa2786e54867405 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 15 May 2025 04:10:11 +0530 Subject: [PATCH 01/18] fix: Duplicate attachments when navigating back to a conversation (#11472) # Pull Request Template ## Description Fixes [CW-4330](https://linear.app/chatwoot/issue/CW-4330/bug-attachments-in-gallery-view-gets-duplicated-when-you-navigate-back) **Cause** When navigating back to a previously viewed conversation, attachments were being duplicated in the Gallery View. This occurred because the `SET_ALL_ATTACHMENTS` mutation was appending new attachments to existing ones `(attachments.push(...data))` instead of replacing them. Each time a user returned to a conversation, the attachment list would grow, showing duplicate images. **Solution** Updated the `SET_ALL_ATTACHMENTS` mutation to replace the entire attachments array instead of appending to it. This change ensures attachments are cleared before adding new ones when returning to a previously viewed conversation, preventing duplicates. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Loom video https://www.loom.com/share/b2472c07e95843f6879724b19ef89311?sid=eed42238-b4bc-4dbe-8ede-a25a13b4610d ## 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 - [ ] 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 --- .../dashboard/store/modules/conversations/index.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index ca0759320..fc6f10979 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -78,10 +78,7 @@ export const mutations = { } }, [types.SET_ALL_ATTACHMENTS](_state, { id, data }) { - const attachments = _state.attachments[id] || []; - - attachments.push(...data); - _state.attachments[id] = [...attachments]; + _state.attachments[id] = [...data]; }, [types.SET_MISSING_MESSAGES](_state, { id, data }) { const [chat] = _state.allConversations.filter(c => c.id === id); From ac884f58f5f19a84a5d374467876feae6165e8a5 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 15 May 2025 12:02:53 +0530 Subject: [PATCH 02/18] fix: Handle Instagram user consent error for first-time message recipients (#11484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Issue** When attempting to send a message to Instagram users who have never previously messaged the connected Instagram account, we encounter a "User consent is required to access user profile" error⁠ ⁠ **Fix** This PR implements proper error handling for cases where user consent is required. We can only access a user's profile after they have initiated contact with the Instagram account⁠ --- app/services/instagram/message_text.rb | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/services/instagram/message_text.rb b/app/services/instagram/message_text.rb index 66445f5a5..e4b88c64d 100644 --- a/app/services/instagram/message_text.rb +++ b/app/services/instagram/message_text.rb @@ -41,10 +41,20 @@ class Instagram::MessageText < Instagram::BaseMessageText # Access token has expired or become invalid. channel.authorization_error! if error_code == 190 + # TODO: Remove this once we have a better way to handle this error. + # https://developers.facebook.com/docs/messenger-platform/instagram/features/user-profile/#user-consent + # The error typically occurs when the connected Instagram account attempts to send a message to a user + # who has never messaged this Instagram account before. + # We can only get consent to access a user's profile if they have previously sent a message to the connected Instagram account. + # In such cases, we receive the error "User consent is required to access user profile". + # We can safely ignore this error. + return if error_code == 230 + Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}") Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}") - ChatwootExceptionTracker.new(parsed_response, account: @inbox.account).capture_exception + exception = StandardError.new("#{error_message} (Code: #{error_code})") + ChatwootExceptionTracker.new(exception, account: @inbox.account).capture_exception end def base_uri From a4c7b738885fac80a16aeef70a32cb6ff3fe49b0 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 14 May 2025 23:58:54 -0700 Subject: [PATCH 03/18] fix: Manage Twilio SMS channel via inbox API (#11457) - Currently Chatwoot manages Twilio Inbox creation in a separate controller , We want to move this to inboxes api so that we have a unified API for inbox related changes --------- Co-authored-by: Muhsin Keloth --- .../channels/twilio_channels_controller.rb | 3 ++ app/models/channel/twilio_sms.rb | 5 +++ app/views/api/v1/models/_inbox.json.jbuilder | 4 ++ .../v1/accounts/inboxes_controller_spec.rb | 42 +++++++++++++++++++ 4 files changed, 54 insertions(+) diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb index ebf8e49dd..58ec3bfca 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -1,3 +1,6 @@ +# TODO : Move this to inboxes controller and deprecate this controller +# No need to retain this controller as we could handle everything centrally in inboxes controller + class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts::BaseController before_action :authorize_request diff --git a/app/models/channel/twilio_sms.rb b/app/models/channel/twilio_sms.rb index 8dbe47703..e4575f58a 100644 --- a/app/models/channel/twilio_sms.rb +++ b/app/models/channel/twilio_sms.rb @@ -30,6 +30,11 @@ class Channel::TwilioSms < ApplicationRecord # The same parameter is used to store api_key_secret if api_key authentication is opted validates :auth_token, presence: true + EDITABLE_ATTRS = [ + :account_sid, + :auth_token + ].freeze + # Must have _one_ of messaging_service_sid _or_ phone_number, and messaging_service_sid is preferred validates :messaging_service_sid, uniqueness: true, presence: true, unless: :phone_number? validates :phone_number, absence: true, if: :messaging_service_sid? diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 42a1a7370..0747924d9 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -62,6 +62,10 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram? json.messaging_service_sid resource.channel.try(:messaging_service_sid) json.phone_number resource.channel.try(:phone_number) json.medium resource.channel.try(:medium) if resource.twilio? +if resource.twilio? && Current.account_user&.administrator? + json.auth_token resource.channel.try(:auth_token) + json.account_sid resource.channel.try(:account_sid) +end if resource.email? ## Email Channel Attributes diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index 7b3dafccb..f8655da44 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -147,6 +147,32 @@ RSpec.describe 'Inboxes API', type: :request do expect(data[:imap_login]).to eq('test@test.com') end + context 'when it is a Twilio inbox' do + let(:twilio_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC123', auth_token: 'secrettoken') } + let(:twilio_inbox) { create(:inbox, channel: twilio_channel, account: account) } + + it 'returns auth_token and account_sid for admin' do + get "/api/v1/accounts/#{account.id}/inboxes/#{twilio_inbox.id}", + headers: admin.create_new_auth_token, + as: :json + expect(response).to have_http_status(:success) + data = JSON.parse(response.body, symbolize_names: true) + expect(data[:auth_token]).to eq('secrettoken') + expect(data[:account_sid]).to eq('AC123') + end + + it "doesn't return auth_token and account_sid for agent" do + create(:inbox_member, user: agent, inbox: twilio_inbox) + get "/api/v1/accounts/#{account.id}/inboxes/#{twilio_inbox.id}", + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:success) + data = JSON.parse(response.body, symbolize_names: true) + expect(data[:auth_token]).to be_nil + expect(data[:account_sid]).to be_nil + end + end + it 'fetch API inbox without hmac token when agent' do api_channel = create(:channel_api, account: account) api_inbox = create(:inbox, channel: api_channel, account: account) @@ -518,6 +544,22 @@ RSpec.describe 'Inboxes API', type: :request do expect(email_channel.reload.email).to eq('emailtest@email.test') end + it 'updates twilio sms inbox when administrator' do + twilio_sms_channel = create(:channel_twilio_sms, account: account) + twilio_sms_inbox = create(:inbox, channel: twilio_sms_channel, account: account) + expect(twilio_sms_inbox.reload.channel.account_sid).not_to eq('account_sid') + expect(twilio_sms_inbox.reload.channel.auth_token).not_to eq('new_auth_token') + + patch "/api/v1/accounts/#{account.id}/inboxes/#{twilio_sms_inbox.id}", + headers: admin.create_new_auth_token, + params: { channel: { account_sid: 'account_sid', auth_token: 'new_auth_token' } }, + as: :json + + expect(response).to have_http_status(:success) + expect(twilio_sms_inbox.reload.channel.account_sid).to eq('account_sid') + expect(twilio_sms_inbox.reload.channel.auth_token).to eq('new_auth_token') + end + it 'updates email inbox with imap when administrator' do email_channel = create(:channel_email, account: account) email_inbox = create(:inbox, channel: email_channel, account: account) From 4f4ef0389b864620fc121382bb50be6ec71e9006 Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 15 May 2025 17:37:04 -0700 Subject: [PATCH 04/18] feat: Add support for persistent copilot threads and messages (#11489) The agents can see the previous conversations with the copilot if needed with this change. We would have to cleanup the data after a while. For now, that is not considered. This PR adds: - A new model for copilot_threads (intentionally named thread instead of conversation to avoid confusion), copilot_messages - Add the controller to fetch previous threads and messages. --- config/routes.rb | 5 +- .../20250512231036_create_copilot_threads.rb | 14 ++++++ .../20250512231037_create_copilot_messages.rb | 13 +++++ db/schema.rb | 27 +++++++++- .../captain/copilot_messages_controller.rb | 25 ++++++++++ .../captain/copilot_threads_controller.rb | 19 +++++++ enterprise/app/models/copilot_message.rb | 27 ++++++++++ enterprise/app/models/copilot_thread.rb | 26 ++++++++++ .../app/models/enterprise/concerns/account.rb | 2 + .../app/models/enterprise/concerns/user.rb | 2 + .../copilot_messages/index.json.jbuilder | 8 +++ .../copilot_threads/index.json.jbuilder | 12 +++++ .../copilot_messages_controller_spec.rb | 33 ++++++++++++ .../copilot_threads_controller_spec.rb | 50 +++++++++++++++++++ spec/factories/captain/copilot_message.rb | 9 ++++ spec/factories/captain/copilot_thread.rb | 8 +++ 16 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20250512231036_create_copilot_threads.rb create mode 100644 db/migrate/20250512231037_create_copilot_messages.rb create mode 100644 enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb create mode 100644 enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb create mode 100644 enterprise/app/models/copilot_message.rb create mode 100644 enterprise/app/models/copilot_thread.rb create mode 100644 enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder create mode 100644 spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb create mode 100644 spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb create mode 100644 spec/factories/captain/copilot_message.rb create mode 100644 spec/factories/captain/copilot_thread.rb diff --git a/config/routes.rb b/config/routes.rb index c623ff053..ee6ec5e8a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -58,9 +58,12 @@ Rails.application.routes.draw do end resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id end - resources :documents, only: [:index, :show, :create, :destroy] resources :assistant_responses resources :bulk_actions, only: [:create] + resources :copilot_threads, only: [:index] do + resources :copilot_messages, only: [:index] + end + resources :documents, only: [:index, :show, :create, :destroy] end resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do delete :avatar, on: :member diff --git a/db/migrate/20250512231036_create_copilot_threads.rb b/db/migrate/20250512231036_create_copilot_threads.rb new file mode 100644 index 000000000..68686e066 --- /dev/null +++ b/db/migrate/20250512231036_create_copilot_threads.rb @@ -0,0 +1,14 @@ +class CreateCopilotThreads < ActiveRecord::Migration[7.0] + def change + create_table :copilot_threads do |t| + t.string :title, null: false + t.references :user, null: false, index: true + t.references :account, null: false, index: true + t.uuid :uuid, null: false, default: 'gen_random_uuid()' + + t.timestamps + end + + add_index :copilot_threads, :uuid, unique: true + end +end diff --git a/db/migrate/20250512231037_create_copilot_messages.rb b/db/migrate/20250512231037_create_copilot_messages.rb new file mode 100644 index 000000000..fd03cc9f8 --- /dev/null +++ b/db/migrate/20250512231037_create_copilot_messages.rb @@ -0,0 +1,13 @@ +class CreateCopilotMessages < ActiveRecord::Migration[7.0] + def change + create_table :copilot_messages do |t| + t.references :copilot_thread, null: false, index: true + t.references :user, null: false, index: true + t.references :account, null: false, index: true + t.string :message_type, null: false + t.jsonb :message, null: false, default: {} + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d858899ab..452354d49 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2025_04_21_085134) do +ActiveRecord::Schema[7.0].define(version: 2025_05_12_231037) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -575,6 +575,31 @@ ActiveRecord::Schema[7.0].define(version: 2025_04_21_085134) do t.index ["waiting_since"], name: "index_conversations_on_waiting_since" end + create_table "copilot_messages", force: :cascade do |t| + t.bigint "copilot_thread_id", null: false + t.bigint "user_id", null: false + t.bigint "account_id", null: false + t.string "message_type", null: false + t.jsonb "message", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_copilot_messages_on_account_id" + t.index ["copilot_thread_id"], name: "index_copilot_messages_on_copilot_thread_id" + t.index ["user_id"], name: "index_copilot_messages_on_user_id" + end + + create_table "copilot_threads", force: :cascade do |t| + t.string "title", null: false + t.bigint "user_id", null: false + t.bigint "account_id", null: false + t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_copilot_threads_on_account_id" + t.index ["user_id"], name: "index_copilot_threads_on_user_id" + t.index ["uuid"], name: "index_copilot_threads_on_uuid", unique: true + end + create_table "csat_survey_responses", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "conversation_id", null: false diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb new file mode 100644 index 000000000..2a30fba48 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb @@ -0,0 +1,25 @@ +class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController + before_action :current_account + before_action -> { check_authorization(Captain::Assistant) } + before_action :set_copilot_thread + + def index + @copilot_messages = @copilot_thread + .copilot_messages + .order(created_at: :asc) + .page(permitted_params[:page] || 1) + .per(1000) + end + + private + + def set_copilot_thread + @copilot_thread = Current.account.copilot_threads.find_by!( + uuid: params[:copilot_thread_id], user_id: Current.user.id + ) + end + + def permitted_params + params.permit(:page) + end +end diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb new file mode 100644 index 000000000..e313f448c --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb @@ -0,0 +1,19 @@ +class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController + before_action :current_account + before_action -> { check_authorization(Captain::Assistant) } + + def index + @copilot_threads = Current.account.copilot_threads + .where(user_id: Current.user.id) + .includes(:user) + .order(created_at: :desc) + .page(permitted_params[:page] || 1) + .per(5) + end + + private + + def permitted_params + params.permit(:page) + end +end diff --git a/enterprise/app/models/copilot_message.rb b/enterprise/app/models/copilot_message.rb new file mode 100644 index 000000000..16ae2c3c9 --- /dev/null +++ b/enterprise/app/models/copilot_message.rb @@ -0,0 +1,27 @@ +# == Schema Information +# +# Table name: copilot_messages +# +# id :bigint not null, primary key +# message :jsonb not null +# message_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# copilot_thread_id :bigint not null +# user_id :bigint not null +# +# Indexes +# +# index_copilot_messages_on_account_id (account_id) +# index_copilot_messages_on_copilot_thread_id (copilot_thread_id) +# index_copilot_messages_on_user_id (user_id) +# +class CopilotMessage < ApplicationRecord + belongs_to :copilot_thread + belongs_to :user + belongs_to :account + + validates :message_type, presence: true, inclusion: { in: %w[user assistant assistant_thinking] } + validates :message, presence: true +end diff --git a/enterprise/app/models/copilot_thread.rb b/enterprise/app/models/copilot_thread.rb new file mode 100644 index 000000000..865418ad7 --- /dev/null +++ b/enterprise/app/models/copilot_thread.rb @@ -0,0 +1,26 @@ +# == Schema Information +# +# Table name: copilot_threads +# +# id :bigint not null, primary key +# title :string not null +# uuid :uuid not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# user_id :bigint not null +# +# Indexes +# +# index_copilot_threads_on_account_id (account_id) +# index_copilot_threads_on_user_id (user_id) +# index_copilot_threads_on_uuid (uuid) UNIQUE +# +class CopilotThread < ApplicationRecord + belongs_to :user + belongs_to :account + has_many :copilot_messages, dependent: :destroy + + validates :title, presence: true + validates :uuid, presence: true, uniqueness: true +end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 4fcb9b34b..4a573a4c4 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -9,5 +9,7 @@ module Enterprise::Concerns::Account has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant' has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document' + + has_many :copilot_threads, dependent: :destroy_async end end diff --git a/enterprise/app/models/enterprise/concerns/user.rb b/enterprise/app/models/enterprise/concerns/user.rb index 5d2687fbf..0e597b8d8 100644 --- a/enterprise/app/models/enterprise/concerns/user.rb +++ b/enterprise/app/models/enterprise/concerns/user.rb @@ -5,6 +5,8 @@ module Enterprise::Concerns::User before_validation :ensure_installation_pricing_plan_quantity, on: :create has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable + has_many :copilot_threads, dependent: :destroy_async + has_many :copilot_messages, dependent: :destroy_async end def ensure_installation_pricing_plan_quantity diff --git a/enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder new file mode 100644 index 000000000..ce0d5b175 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder @@ -0,0 +1,8 @@ +json.payload do + json.array! @copilot_messages do |message| + json.id message.id + json.message message.message + json.message_type message.message_type + json.created_at message.created_at.to_i + end +end diff --git a/enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder new file mode 100644 index 000000000..c06182ffd --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder @@ -0,0 +1,12 @@ +json.payload do + json.array! @copilot_threads do |thread| + json.id thread.id + json.title thread.title + json.uuid thread.uuid + json.created_at thread.created_at.to_i + json.user do + json.id thread.user.id + json.name thread.user.name + end + end +end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb new file mode 100644 index 000000000..0ccca90c5 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :request do + let(:account) { create(:account) } + let(:user) { create(:user, account: account, role: :administrator) } + let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) } + let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, user: user, account: account) } + + describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.uuid}/copilot_messages' do + context 'when it is an authenticated user' do + it 'returns all messages' do + get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.uuid}/copilot_messages", + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['payload'].length).to eq(1) + expect(json_response['payload'][0]['id']).to eq(copilot_message.id) + end + end + + context 'when thread uuid is invalid' do + it 'returns not found error' do + get "/api/v1/accounts/#{account.id}/captain/copilot_threads/invalid-uuid/copilot_messages", + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb new file mode 100644 index 000000000..8533a2d1d --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb @@ -0,0 +1,50 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + + def json_response + JSON.parse(response.body, symbolize_names: true) + end + + describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads' do + context 'when it is an un-authenticated user' do + it 'does not fetch copilot threads' do + get "/api/v1/accounts/#{account.id}/captain/copilot_threads", + as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'fetches copilot threads for the current user' do + # Create threads for the current agent + create_list(:captain_copilot_thread, 3, account: account, user: agent) + # Create threads for another user (should not be included) + create_list(:captain_copilot_thread, 2, account: account, user: admin) + + get "/api/v1/accounts/#{account.id}/captain/copilot_threads", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:payload].length).to eq(3) + + expect(json_response[:payload].map { |thread| thread[:user][:id] }.uniq).to eq([agent.id]) + end + + it 'returns threads in descending order of creation' do + threads = create_list(:captain_copilot_thread, 3, account: account, user: agent) + + get "/api/v1/accounts/#{account.id}/captain/copilot_threads", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:payload].pluck(:id)).to eq(threads.reverse.pluck(:id)) + end + end + end +end diff --git a/spec/factories/captain/copilot_message.rb b/spec/factories/captain/copilot_message.rb new file mode 100644 index 000000000..78f9f202e --- /dev/null +++ b/spec/factories/captain/copilot_message.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :captain_copilot_message, class: 'CopilotMessage' do + account + user + copilot_thread { association :captain_copilot_thread } + message { { content: 'This is a test message' } } + message_type { 'user' } + end +end diff --git a/spec/factories/captain/copilot_thread.rb b/spec/factories/captain/copilot_thread.rb new file mode 100644 index 000000000..fee78a7e7 --- /dev/null +++ b/spec/factories/captain/copilot_thread.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :captain_copilot_thread, class: 'CopilotThread' do + account + user + title { Faker::Lorem.sentence } + uuid { SecureRandom.uuid } + end +end From bce1f58e86a298a7694d2c35bfa4c9eb5f85d14b Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 15 May 2025 17:47:37 -0700 Subject: [PATCH 05/18] chore: Update LLM formatter classes to include additional details (#11491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces support for optionally exposing more data during LLM function calls. This will be useful as we expand Copilot’s capabilities. Changes included: - Add support for ArticleLlmFormatter - Add missing specs for ContactLLMFormatter and ArticleLLMFormatter - Add additional spec for ConversationLLMFormatter based on config --- app/models/article.rb | 1 + app/models/concerns/llm_formattable.rb | 4 +- .../llm_formatter/article_llm_formatter.rb | 22 ++++++ .../llm_formatter/contact_llm_formatter.rb | 2 +- .../conversation_llm_formatter.rb | 3 +- .../llm_formatter/default_llm_formatter.rb | 2 +- .../llm_text_formatter_service.rb | 4 +- spec/models/article_spec.rb | 22 ++++++ .../article_llm_formatter_spec.rb | 44 +++++++++++ .../contact_llm_formatter_spec.rb | 78 +++++++++++++++++++ .../conversation_llm_formatter_spec.rb | 14 ++++ 11 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 app/services/llm_formatter/article_llm_formatter.rb create mode 100644 spec/services/llm_formatter/article_llm_formatter_spec.rb create mode 100644 spec/services/llm_formatter/contact_llm_formatter_spec.rb diff --git a/app/models/article.rb b/app/models/article.rb index 48e0529a2..14450b574 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -33,6 +33,7 @@ # class Article < ApplicationRecord include PgSearch::Model + include LlmFormattable has_many :associated_articles, class_name: :Article, diff --git a/app/models/concerns/llm_formattable.rb b/app/models/concerns/llm_formattable.rb index 086ccc46a..0cdc76718 100644 --- a/app/models/concerns/llm_formattable.rb +++ b/app/models/concerns/llm_formattable.rb @@ -1,7 +1,7 @@ module LlmFormattable extend ActiveSupport::Concern - def to_llm_text - LlmFormatter::LlmTextFormatterService.new(self).format + def to_llm_text(config = {}) + LlmFormatter::LlmTextFormatterService.new(self).format(config) end end diff --git a/app/services/llm_formatter/article_llm_formatter.rb b/app/services/llm_formatter/article_llm_formatter.rb new file mode 100644 index 000000000..5df7976b7 --- /dev/null +++ b/app/services/llm_formatter/article_llm_formatter.rb @@ -0,0 +1,22 @@ +class LlmFormatter::ArticleLlmFormatter + attr_reader :article + + def initialize(article) + @article = article + end + + def format(*) + <<~TEXT + Title: #{article.title} + ID: #{article.id} + Status: #{article.status} + Category: #{article.category&.name || 'Uncategorized'} + Author: #{article.author&.name || 'Unknown'} + Views: #{article.views} + Created At: #{article.created_at} + Updated At: #{article.updated_at} + Content: + #{article.content} + TEXT + end +end diff --git a/app/services/llm_formatter/contact_llm_formatter.rb b/app/services/llm_formatter/contact_llm_formatter.rb index 9dcfcd299..586f8743b 100644 --- a/app/services/llm_formatter/contact_llm_formatter.rb +++ b/app/services/llm_formatter/contact_llm_formatter.rb @@ -1,5 +1,5 @@ class LlmFormatter::ContactLlmFormatter < LlmFormatter::DefaultLlmFormatter - def format + def format(*) sections = [] sections << "Contact ID: ##{@record.id}" sections << 'Contact Attributes:' diff --git a/app/services/llm_formatter/conversation_llm_formatter.rb b/app/services/llm_formatter/conversation_llm_formatter.rb index 07afc2cac..1444d75c1 100644 --- a/app/services/llm_formatter/conversation_llm_formatter.rb +++ b/app/services/llm_formatter/conversation_llm_formatter.rb @@ -1,5 +1,5 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter - def format + def format(config = {}) sections = [] sections << "Conversation ID: ##{@record.display_id}" sections << "Channel: #{@record.inbox.channel.name}" @@ -10,6 +10,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter 'No messages in this conversation' end + sections << "Contact Details: #{@record.contact.to_llm_text}" if config[:include_contact_details] sections.join("\n") end diff --git a/app/services/llm_formatter/default_llm_formatter.rb b/app/services/llm_formatter/default_llm_formatter.rb index b82a039cf..3ac1fa7f4 100644 --- a/app/services/llm_formatter/default_llm_formatter.rb +++ b/app/services/llm_formatter/default_llm_formatter.rb @@ -3,7 +3,7 @@ class LlmFormatter::DefaultLlmFormatter @record = record end - def format + def format(*) # override this end end diff --git a/app/services/llm_formatter/llm_text_formatter_service.rb b/app/services/llm_formatter/llm_text_formatter_service.rb index 0f1f37cf8..198f09d58 100644 --- a/app/services/llm_formatter/llm_text_formatter_service.rb +++ b/app/services/llm_formatter/llm_text_formatter_service.rb @@ -3,9 +3,9 @@ class LlmFormatter::LlmTextFormatterService @record = record end - def format + def format(config = {}) formatter_class = find_formatter - formatter_class.new(@record).format + formatter_class.new(@record).format(config) end private diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb index 0dc17d472..161f3541d 100644 --- a/spec/models/article_spec.rb +++ b/spec/models/article_spec.rb @@ -167,4 +167,26 @@ RSpec.describe Article do end end end + + describe '#to_llm_text' do + it 'returns formatted article text' do + category = create(:category, name: 'Test Category', slug: 'test_category', portal_id: portal_1.id) + article = create(:article, title: 'Test Article', category_id: category.id, content: 'This is the content', portal_id: portal_1.id, + author_id: user.id) + expected_output = <<~TEXT + Title: #{article.title} + ID: #{article.id} + Status: #{article.status} + Category: #{category.name} + Author: #{user.name} + Views: #{article.views} + Created At: #{article.created_at} + Updated At: #{article.updated_at} + Content: + #{article.content} + TEXT + + expect(article.to_llm_text).to eq(expected_output) + end + end end diff --git a/spec/services/llm_formatter/article_llm_formatter_spec.rb b/spec/services/llm_formatter/article_llm_formatter_spec.rb new file mode 100644 index 000000000..0f47beddb --- /dev/null +++ b/spec/services/llm_formatter/article_llm_formatter_spec.rb @@ -0,0 +1,44 @@ +require 'rails_helper' + +RSpec.describe LlmFormatter::ArticleLlmFormatter do + let(:account) { create(:account) } + let(:portal) { create(:portal, account: account) } + let(:category) { create(:category, slug: 'test_category', portal: portal, account: account) } + let(:author) { create(:user, account: account) } + let(:formatter) { described_class.new(article) } + + describe '#format' do + context 'when article has all details' do + let(:article) do + create(:article, + slug: 'test_article', + portal: portal, category: category, author: author, views: 100, account: account) + end + + it 'formats article details correctly' do + expected_output = <<~TEXT + Title: #{article.title} + ID: #{article.id} + Status: #{article.status} + Category: #{category.name} + Author: #{author.name} + Views: #{article.views} + Created At: #{article.created_at} + Updated At: #{article.updated_at} + Content: + #{article.content} + TEXT + + expect(formatter.format).to eq(expected_output) + end + end + + context 'when article has no category' do + let(:article) { create(:article, portal: portal, category: nil, author: author, account: account) } + + it 'shows Uncategorized for category' do + expect(formatter.format).to include('Category: Uncategorized') + end + end + end +end diff --git a/spec/services/llm_formatter/contact_llm_formatter_spec.rb b/spec/services/llm_formatter/contact_llm_formatter_spec.rb new file mode 100644 index 000000000..bf3345b98 --- /dev/null +++ b/spec/services/llm_formatter/contact_llm_formatter_spec.rb @@ -0,0 +1,78 @@ +require 'rails_helper' + +RSpec.describe LlmFormatter::ContactLlmFormatter do + let(:account) { create(:account) } + let(:contact) { create(:contact, account: account, name: 'John Doe', email: 'john@example.com', phone_number: '+1234567890') } + let(:formatter) { described_class.new(contact) } + + describe '#format' do + context 'when contact has no notes' do + it 'formats contact details correctly' do + expected_output = [ + "Contact ID: ##{contact.id}", + 'Contact Attributes:', + 'Name: John Doe', + 'Email: john@example.com', + 'Phone: +1234567890', + 'Location: ', + 'Country Code: ', + 'Contact Notes:', + 'No notes for this contact' + ].join("\n") + + expect(formatter.format).to eq(expected_output) + end + end + + context 'when contact has notes' do + before do + create(:note, account: account, contact: contact, content: 'First interaction') + create(:note, account: account, contact: contact, content: 'Follow up needed') + end + + it 'includes notes in the output' do + expected_output = [ + "Contact ID: ##{contact.id}", + 'Contact Attributes:', + 'Name: John Doe', + 'Email: john@example.com', + 'Phone: +1234567890', + 'Location: ', + 'Country Code: ', + 'Contact Notes:', + ' - First interaction', + ' - Follow up needed' + ].join("\n") + + expect(formatter.format).to eq(expected_output) + end + end + + context 'when contact has custom attributes' do + let!(:custom_attribute) do + create(:custom_attribute_definition, account: account, attribute_model: 'contact_attribute', attribute_display_name: 'Company') + end + + before do + contact.update(custom_attributes: { custom_attribute.attribute_key => 'Acme Inc' }) + end + + it 'includes custom attributes in the output' do + expected_output = [ + "Contact ID: ##{contact.id}", + 'Contact Attributes:', + 'Name: John Doe', + 'Email: john@example.com', + 'Phone: +1234567890', + 'Location: ', + 'Country Code: ', + 'Company: Acme Inc', + 'Contact Notes:', + 'No notes for this contact' + ].join("\n") + + expect(formatter.format).to eq(expected_output) + end + end + end +end diff --git a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb index 3838b8126..93fec14f7 100644 --- a/spec/services/llm_formatter/conversation_llm_formatter_spec.rb +++ b/spec/services/llm_formatter/conversation_llm_formatter_spec.rb @@ -47,5 +47,19 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do expect(formatter.format).to eq(expected_output) end end + + context 'when include_contact_details is true' do + it 'includes contact details' do + expected_output = [ + "Conversation ID: ##{conversation.display_id}", + "Channel: #{conversation.inbox.channel.name}", + 'Message History:', + 'No messages in this conversation', + "Contact Details: #{conversation.contact.to_llm_text}" + ].join("\n") + + expect(formatter.format(include_contact_details: true)).to eq(expected_output) + end + end end end From e9cda40b71efa03e99d7679ab7ac5d670d10f59e Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Fri, 16 May 2025 10:26:37 +0700 Subject: [PATCH 06/18] fix: Twilio multiple attachment fix (#11452) --- app/controllers/twilio/callback_controller.rb | 7 ++-- .../twilio/incoming_message_service.rb | 29 ++++++++------- .../twilio/incoming_message_service_spec.rb | 35 +++++++++++++++++-- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index ff5db952d..ff16e9386 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -24,9 +24,10 @@ class Twilio::CallbackController < ApplicationController :Body, :ToCountry, :FromState, - :MediaUrl0, - :MediaContentType0, - :MessagingServiceSid + *Array.new(10) { |i| :"MediaUrl#{i}" }, + *Array.new(10) { |i| :"MediaContentType#{i}" }, + :MessagingServiceSid, + :NumMedia ) end end diff --git a/app/services/twilio/incoming_message_service.rb b/app/services/twilio/incoming_message_service.rb index 5c5854ee6..7a335c87d 100644 --- a/app/services/twilio/incoming_message_service.rb +++ b/app/services/twilio/incoming_message_service.rb @@ -106,15 +106,22 @@ class Twilio::IncomingMessageService end def attach_files - return if params[:MediaUrl0].blank? + num_media = params[:NumMedia].to_i + return if num_media.zero? - attachment_file = download_attachment_file + num_media.times do |i| + media_url = params[:"MediaUrl#{i}"] + attach_single_file(media_url) if media_url.present? + end + end + def attach_single_file(media_url) + attachment_file = download_attachment_file(media_url) return if attachment_file.blank? @message.attachments.new( account_id: @message.account_id, - file_type: file_type(params[:MediaContentType0]), + file_type: file_type(attachment_file.content_type), file: { io: attachment_file, filename: attachment_file.original_filename, @@ -123,24 +130,22 @@ class Twilio::IncomingMessageService ) end - def download_attachment_file - download_with_auth + def download_attachment_file(media_url) + download_with_auth(media_url) rescue Down::Error, Down::ClientError => e - handle_download_attachment_error(e) + handle_download_attachment_error(e, media_url) end - def download_with_auth + def download_with_auth(media_url) Down.download( - params[:MediaUrl0], - # https://support.twilio.com/hc/en-us/articles/223183748-Protect-Media-Access-with-HTTP-Basic-Authentication-for-Programmable-Messaging + media_url, http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid] ) end - # This is just a temporary workaround since some users have not yet enabled media protection. We will remove this in the future. - def handle_download_attachment_error(error) + def handle_download_attachment_error(error, media_url) Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying" - Down.download(params[:MediaUrl0]) + Down.download(media_url) rescue StandardError => e Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping" nil diff --git a/spec/services/twilio/incoming_message_service_spec.rb b/spec/services/twilio/incoming_message_service_spec.rb index 5f4dcec69..c8812e45d 100644 --- a/spec/services/twilio/incoming_message_service_spec.rb +++ b/spec/services/twilio/incoming_message_service_spec.rb @@ -173,7 +173,7 @@ describe Twilio::IncomingMessageService do context 'when a message with an attachment is received' do before do stub_request(:get, 'https://chatwoot-assets.local/sample.png') - .to_return(status: 200, body: 'image data', headers: {}) + .to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' }) end let(:params_with_attachment) do @@ -203,7 +203,7 @@ describe Twilio::IncomingMessageService do .to_raise(Down::Error.new('Download error')) stub_request(:get, 'https://chatwoot-assets.local/sample.png') - .to_return(status: 200, body: 'image data', headers: {}) + .to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' }) end let(:params_with_attachment_error) do @@ -229,5 +229,36 @@ describe Twilio::IncomingMessageService do expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image') end end + + context 'when a message with multiple attachments is received' do + before do + stub_request(:get, 'https://chatwoot-assets.local/sample.png') + .to_return(status: 200, body: 'image data 1', headers: { 'Content-Type' => 'image/png' }) + stub_request(:get, 'https://chatwoot-assets.local/sample.jpg') + .to_return(status: 200, body: 'image data 2', headers: { 'Content-Type' => 'image/jpeg' }) + end + + let(:params_with_multiple_attachments) do + { + SmsSid: 'SMxx', + From: '+12345', + AccountSid: 'ACxxx', + MessagingServiceSid: twilio_channel.messaging_service_sid, + Body: 'testing multiple media', + NumMedia: '2', + MediaContentType0: 'image/png', + MediaUrl0: 'https://chatwoot-assets.local/sample.png', + MediaContentType1: 'image/jpeg', + MediaUrl1: 'https://chatwoot-assets.local/sample.jpg' + } + end + + it 'creates a new message with multiple media attachments in existing conversation' do + described_class.new(params: params_with_multiple_attachments).perform + expect(conversation.reload.messages.last.content).to eq('testing multiple media') + expect(conversation.reload.messages.last.attachments.count).to eq(2) + expect(conversation.reload.messages.last.attachments.map(&:file_type)).to contain_exactly('image', 'image') + end + end end end From d0611cb7f2dd39c8df98a7054d05848e4d54eb61 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 16 May 2025 14:18:52 +0530 Subject: [PATCH 07/18] feat: Improve CSAT responses (#11485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description This PR introduces basic customization options for the CSAT survey: * **Display Type**: Option to use star ratings instead of emojis. * **Message Text**: Customize the survey message (up to 200 characters). * **Survey Rules**: Send surveys based on labels — trigger when a conversation has or doesn't have a specific label. Fixes https://linear.app/chatwoot/document/improve-csat-responses-a61cf30e054e ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Loom videos **Website Channel (Widget)** https://www.loom.com/share/7f47836cde7940ae9d17b7997d060a18?sid=aad2ad0a-140a-4a09-8829-e01fa2e102c5 **Email Channel (Survey link)** https://www.loom.com/share/e92f4c4c0f73417ba300a25885e093ce?sid=4bb006f0-1c2a-4352-a232-8bf684e3d757 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] 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: Pranav --- .../api/v1/accounts/inboxes_controller.rb | 18 +- .../filter/inputs/FilterSelect.vue | 6 +- .../components-next/message/bubbles/CSAT.vue | 33 ++- .../dashboard/i18n/locale/en/inboxMgmt.json | 31 ++- .../dashboard/settings/inbox/Settings.vue | 27 +- .../settingsPage/CustomerSatisfactionPage.vue | 233 ++++++++++++++++++ .../components/CSATDisplayTypeSelector.vue | 26 ++ .../components/CSATEmojiInput.vue | 43 ++++ .../settingsPage/components/CSATStarInput.vue | 36 +++ .../components/CustomerSatisfaction.vue | 33 ++- .../shared/components/StarRating.vue | 65 +++++ app/javascript/shared/constants/messages.js | 5 + app/javascript/survey/App.vue | 2 +- app/javascript/survey/assets/scss/woot.scss | 1 + app/javascript/survey/components/Banner.vue | 2 +- app/javascript/survey/components/Feedback.vue | 2 +- app/javascript/survey/views/Response.vue | 44 +++- .../widget/components/AgentMessageBubble.vue | 2 + app/models/inbox.rb | 1 + .../message_templates/template/csat_survey.rb | 58 ++++- app/views/api/v1/models/_inbox.json.jbuilder | 1 + .../api/v1/models/_csat_survey.json.jbuilder | 2 + ...250514045638_add_csat_config_to_inboxes.rb | 5 + db/schema.rb | 3 +- .../v1/accounts/inboxes_controller_spec.rb | 88 +++++++ .../template/csat_survey_spec.rb | 99 +++++++- 26 files changed, 812 insertions(+), 54 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/components/CSATDisplayTypeSelector.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/components/CSATEmojiInput.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/components/CSATStarInput.vue create mode 100644 app/javascript/shared/components/StarRating.vue create mode 100644 db/migrate/20250514045638_add_csat_config_to_inboxes.rb diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 011faaf28..61d16b2ca 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -42,7 +42,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def update - @inbox.update!(permitted_params.except(:channel)) + inbox_params = permitted_params.except(:channel, :csat_config) + inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present? + @inbox.update!(inbox_params) update_inbox_working_hours update_channel if channel_update_required? end @@ -121,10 +123,22 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController @inbox.channel.save! end + def format_csat_config(config) + { + display_type: config['display_type'] || 'emoji', + message: config['message'] || '', + survey_rules: { + operator: config.dig('survey_rules', 'operator') || 'contains', + values: config.dig('survey_rules', 'values') || [] + } + } + end + def inbox_attributes [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, - :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name] + :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, + { csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }] end def permitted_params(channel_attributes = []) diff --git a/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue b/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue index db420920b..56bce8f9d 100644 --- a/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue +++ b/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue @@ -25,6 +25,10 @@ const props = defineProps({ type: String, default: 'faded', }, + label: { + type: String, + default: null, + }, }); const selected = defineModel({ @@ -56,7 +60,7 @@ const updateSelected = newValue => { :variant :icon="iconToRender" :trailing-icon="selectedOption.icon ? false : true" - :label="hideLabel ? null : selectedOption.label" + :label="label || (hideLabel ? null : selectedOption.label)" @click="toggle" /> diff --git a/app/javascript/dashboard/components-next/message/bubbles/CSAT.vue b/app/javascript/dashboard/components-next/message/bubbles/CSAT.vue index 211840944..6f86bd868 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/CSAT.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/CSAT.vue @@ -2,10 +2,10 @@ import { computed } from 'vue'; import BaseBubble from './Base.vue'; import { useI18n } from 'vue-i18n'; -import { CSAT_RATINGS } from 'shared/constants/messages'; +import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages'; import { useMessageContext } from '../provider.js'; -const { contentAttributes } = useMessageContext(); +const { contentAttributes, content } = useMessageContext(); const { t } = useI18n(); const response = computed(() => { @@ -16,6 +16,14 @@ const isRatingSubmitted = computed(() => { return !!response.value.rating; }); +const displayType = computed(() => { + return contentAttributes.value?.displayType || CSAT_DISPLAY_TYPES.EMOJI; +}); + +const isStarRating = computed(() => { + return displayType.value === CSAT_DISPLAY_TYPES.STAR; +}); + const rating = computed(() => { if (isRatingSubmitted.value) { return CSAT_RATINGS.find( @@ -25,16 +33,33 @@ const rating = computed(() => { return null; }); + +const starRatingValue = computed(() => { + return response.value.rating || 0; +});