From b26862e3d8b68c9383666be1160b13d645ddbad2 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 26 Jun 2025 19:16:06 +0530 Subject: [PATCH 01/28] feat: notion OAuth setup (#11765) Co-authored-by: Muhsin Keloth --- .../integrations/notion_controller.rb | 14 +++ .../notion/authorizations_controller.rb | 21 ++++ app/controllers/concerns/notion_concern.rb | 21 ++++ .../notion/callbacks_controller.rb | 36 ++++++ .../super_admin/app_configs_controller.rb | 1 + app/javascript/dashboard/api/notion_auth.js | 14 +++ .../i18n/locale/en/integrations.json | 8 ++ .../settings/integrations/Notion.vue | 80 +++++++++++++ .../integrations/integrations.routes.js | 10 ++ app/models/integrations/app.rb | 14 ++- app/models/integrations/hook.rb | 4 + .../super_admin/application/_icons.html.erb | 5 + config/features.yml | 3 + config/installation_config.yml | 19 +++ config/integration/apps.yml | 6 + config/locales/en.yml | 4 + config/routes.rb | 10 ++ .../app/helpers/super_admin/features.yml | 6 + .../images/integrations/notion-dark.png | Bin 0 -> 20124 bytes .../dashboard/images/integrations/notion.png | Bin 0 -> 24494 bytes .../notion/authorization_controller_spec.rb | 53 +++++++++ .../concerns/notion_concern_spec.rb | 56 +++++++++ .../notion/callbacks_controller_spec.rb | 112 ++++++++++++++++++ 23 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/v1/accounts/integrations/notion_controller.rb create mode 100644 app/controllers/api/v1/accounts/notion/authorizations_controller.rb create mode 100644 app/controllers/concerns/notion_concern.rb create mode 100644 app/controllers/notion/callbacks_controller.rb create mode 100644 app/javascript/dashboard/api/notion_auth.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrations/Notion.vue create mode 100644 public/dashboard/images/integrations/notion-dark.png create mode 100644 public/dashboard/images/integrations/notion.png create mode 100644 spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb create mode 100644 spec/controllers/concerns/notion_concern_spec.rb create mode 100644 spec/controllers/notion/callbacks_controller_spec.rb diff --git a/app/controllers/api/v1/accounts/integrations/notion_controller.rb b/app/controllers/api/v1/accounts/integrations/notion_controller.rb new file mode 100644 index 000000000..dff6ccece --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/notion_controller.rb @@ -0,0 +1,14 @@ +class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController + before_action :fetch_hook, only: [:destroy] + + def destroy + @hook.destroy! + head :ok + end + + private + + def fetch_hook + @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion') + end +end \ No newline at end of file diff --git a/app/controllers/api/v1/accounts/notion/authorizations_controller.rb b/app/controllers/api/v1/accounts/notion/authorizations_controller.rb new file mode 100644 index 000000000..bb9b2f858 --- /dev/null +++ b/app/controllers/api/v1/accounts/notion/authorizations_controller.rb @@ -0,0 +1,21 @@ +class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController + include NotionConcern + + def create + redirect_url = notion_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/notion/callback", + response_type: 'code', + owner: 'user', + state: state, + client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil) + } + ) + + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end +end \ No newline at end of file diff --git a/app/controllers/concerns/notion_concern.rb b/app/controllers/concerns/notion_concern.rb new file mode 100644 index 000000000..2b94fe63b --- /dev/null +++ b/app/controllers/concerns/notion_concern.rb @@ -0,0 +1,21 @@ +module NotionConcern + extend ActiveSupport::Concern + + def notion_client + app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil) + app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil) + + ::OAuth2::Client.new(app_id, app_secret, { + site: 'https://api.notion.com', + authorize_url: 'https://api.notion.com/v1/oauth/authorize', + token_url: 'https://api.notion.com/v1/oauth/token', + auth_scheme: :basic_auth + }) + end + + private + + def scope + '' + end +end diff --git a/app/controllers/notion/callbacks_controller.rb b/app/controllers/notion/callbacks_controller.rb new file mode 100644 index 000000000..94030fc8e --- /dev/null +++ b/app/controllers/notion/callbacks_controller.rb @@ -0,0 +1,36 @@ +class Notion::CallbacksController < OauthCallbackController + include NotionConcern + + private + + def provider_name + 'notion' + end + + def oauth_client + notion_client + end + + def handle_response + hook = account.hooks.new( + access_token: parsed_body['access_token'], + status: 'enabled', + app_id: 'notion', + settings: { + token_type: parsed_body['token_type'], + workspace_name: parsed_body['workspace_name'], + workspace_id: parsed_body['workspace_id'], + workspace_icon: parsed_body['workspace_icon'], + bot_id: parsed_body['bot_id'], + owner: parsed_body['owner'] + } + ) + + hook.save! + redirect_to notion_redirect_uri + end + + def notion_redirect_uri + "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion" + end +end \ No newline at end of file diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 204bfc95b..771f9f28c 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -39,6 +39,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], + 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] } diff --git a/app/javascript/dashboard/api/notion_auth.js b/app/javascript/dashboard/api/notion_auth.js new file mode 100644 index 000000000..8a0027f9b --- /dev/null +++ b/app/javascript/dashboard/api/notion_auth.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class NotionOAuthClient extends ApiClient { + constructor() { + super('notion', { accountScoped: true }); + } + + generateAuthorization() { + return axios.post(`${this.url}/authorization`); + } +} + +export default new NotionOAuthClient(); diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 41f63d0a2..b3e091722 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -328,6 +328,14 @@ "DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.", "BUTTON_TEXT": "Connect Linear workspace" } + }, + "NOTION": { + "DELETE": { + "TITLE": "Are you sure you want to delete the Notion integration?", + "MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.", + "CONFIRM": "Yes, delete", + "CANCEL": "Cancel" + } } }, "CAPTAIN": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Notion.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Notion.vue new file mode 100644 index 000000000..c2d63ad22 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Notion.vue @@ -0,0 +1,80 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js index e50eccb3a..bb700fc74 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js @@ -8,6 +8,7 @@ import DashboardApps from './DashboardApps/Index.vue'; import Slack from './Slack.vue'; import SettingsContent from '../Wrapper.vue'; import Linear from './Linear.vue'; +import Notion from './Notion.vue'; import Shopify from './Shopify.vue'; export default { @@ -90,6 +91,15 @@ export default { }, props: route => ({ code: route.query.code }), }, + { + path: 'notion', + name: 'settings_integrations_notion', + component: Notion, + meta: { + permissions: ['administrator'], + }, + props: route => ({ code: route.query.code }), + }, { path: 'shopify', name: 'settings_integrations_shopify', diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb index dfe889bfa..3b5cd821a 100644 --- a/app/models/integrations/app.rb +++ b/app/models/integrations/app.rb @@ -55,9 +55,11 @@ class Integrations::App when 'linear' GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present? when 'shopify' - account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present? + shopify_enabled?(account) when 'leadsquared' account.feature_enabled?('crm_integration') + when 'notion' + notion_enabled?(account) else true end @@ -113,4 +115,14 @@ class Integrations::App all.detect { |app| app.id == params[:id] } end end + + private + + def shopify_enabled?(account) + account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present? + end + + def notion_enabled?(account) + account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present? + end end diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index e7300b525..ca77fa13d 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -53,6 +53,10 @@ class Integrations::Hook < ApplicationRecord app_id == 'dialogflow' end + def notion? + app_id == 'notion' + end + def disable update(status: 'disabled') end diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb index fabff914b..6669fe87d 100644 --- a/app/views/super_admin/application/_icons.html.erb +++ b/app/views/super_admin/application/_icons.html.erb @@ -156,9 +156,14 @@ + + + + + diff --git a/config/features.yml b/config/features.yml index 95f7e33d4..5171b1c01 100644 --- a/config/features.yml +++ b/config/features.yml @@ -173,3 +173,6 @@ display_name: Voice Channel enabled: false chatwoot_internal: true +- name: notion_integration + display_name: Notion Integration + enabled: false diff --git a/config/installation_config.yml b/config/installation_config.yml index 66538d4ab..a089a4ea7 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -288,6 +288,25 @@ type: secret ## ------ End of Configs added for Linear ------ ## +## ------ Configs added for Notion ------ ## +- name: NOTION_CLIENT_ID + display_title: 'Notion Client ID' + value: + locked: false + description: 'Notion client ID' +- name: NOTION_CLIENT_SECRET + display_title: 'Notion Client Secret' + value: + locked: false + description: 'Notion client secret' + type: secret +- name: NOTION_VERSION + display_title: 'Notion Version' + value: '2022-06-28' + locked: false + description: 'Notion version' +## ------ End of Configs added for Notion ------ ## + ## ------ Configs added for Slack ------ ## - name: SLACK_CLIENT_ID display_title: 'Slack Client ID' diff --git a/config/integration/apps.yml b/config/integration/apps.yml index 1faf35670..dd5c722a4 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -63,6 +63,12 @@ linear: action: https://linear.app/oauth/authorize hook_type: account allow_multiple_hooks: false +notion: + id: notion + logo: notion.png + i18n_key: notion + hook_type: account + allow_multiple_hooks: false slack: id: slack logo: slack.png diff --git a/config/locales/en.yml b/config/locales/en.yml index 181a9399f..a41a009cf 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -257,6 +257,10 @@ en: name: 'Linear' short_description: 'Create and link Linear issues directly from conversations.' description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.' + notion: + name: 'Notion' + short_description: 'Integrate databases, documents and pages directly with Captain.' + description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.' shopify: name: 'Shopify' short_description: 'Access order details and customer data from your Shopify store.' diff --git a/config/routes.rb b/config/routes.rb index 203a954d1..41ee52f42 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -228,6 +228,10 @@ Rails.application.routes.draw do resource :authorization, only: [:create] end + namespace :notion do + resource :authorization, only: [:create] + end + resources :webhooks, only: [:index, :create, :update, :destroy] namespace :integrations do resources :apps, only: [:index, :show] @@ -265,6 +269,11 @@ Rails.application.routes.draw do get :linked_issues end end + resource :notion, controller: 'notion', only: [] do + collection do + delete :destroy + end + end end resources :working_hours, only: [:update] @@ -493,6 +502,7 @@ Rails.application.routes.draw do get 'microsoft/callback', to: 'microsoft/callbacks#show' get 'google/callback', to: 'google/callbacks#show' get 'instagram/callback', to: 'instagram/callbacks#show' + get 'notion/callback', to: 'notion/callbacks#show' # ---------------------------------------------------------------------- # Routes for external service verifications get '.well-known/assetlinks.json' => 'android_app#assetlinks' diff --git a/enterprise/app/helpers/super_admin/features.yml b/enterprise/app/helpers/super_admin/features.yml index c20de2dfa..e86f66832 100644 --- a/enterprise/app/helpers/super_admin/features.yml +++ b/enterprise/app/helpers/super_admin/features.yml @@ -91,6 +91,12 @@ linear: enabled: true icon: 'icon-linear' config_key: 'linear' +notion: + name: 'Notion' + description: 'Configuration for setting up Notion Integration' + enabled: true + icon: 'icon-notion' + config_key: 'notion' slack: name: 'Slack' description: 'Configuration for setting up Slack Integration' diff --git a/public/dashboard/images/integrations/notion-dark.png b/public/dashboard/images/integrations/notion-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..7d15c715e6026de6eb112794b4cd5f1248c82b33 GIT binary patch literal 20124 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 literal 0 HcmV?d00001 diff --git a/public/dashboard/images/integrations/notion.png b/public/dashboard/images/integrations/notion.png new file mode 100644 index 0000000000000000000000000000000000000000..a358e8a51b8d80ce8a1599118bd6bc7ef0986393 GIT binary patch literal 24494 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 B7)SsB literal 0 HcmV?d00001 diff --git a/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb new file mode 100644 index 000000000..ac4bc2841 --- /dev/null +++ b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe 'Notion Authorization API', type: :request do + let(:account) { create(:account) } + + describe 'POST /api/v1/accounts/{account.id}/notion/authorization' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/notion/authorization" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + it 'returns unauthorized for agent' do + post "/api/v1/accounts/#{account.id}/notion/authorization", + headers: agent.create_new_auth_token, + params: { email: administrator.email }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'creates a new authorization and returns the redirect url' do + post "/api/v1/accounts/#{account.id}/notion/authorization", + headers: administrator.create_new_auth_token, + params: { email: administrator.email }, + as: :json + + expect(response).to have_http_status(:success) + + # Validate URL components + url = response.parsed_body['url'] + uri = URI.parse(url) + params = CGI.parse(uri.query) + + expect(url).to start_with('https://api.notion.com/v1/oauth/authorize') + expect(params['response_type']).to eq(['code']) + expect(params['owner']).to eq(['user']) + expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/notion/callback"]) + + # Validate state parameter exists and can be decoded back to the account + expect(params['state']).to be_present + decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default') + expect(decoded_account).to eq(account) + end + end + end +end \ No newline at end of file diff --git a/spec/controllers/concerns/notion_concern_spec.rb b/spec/controllers/concerns/notion_concern_spec.rb new file mode 100644 index 000000000..7ae11b17d --- /dev/null +++ b/spec/controllers/concerns/notion_concern_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +RSpec.describe NotionConcern, type: :concern do + let(:controller_class) do + Class.new do + include NotionConcern + end + end + + let(:controller) { controller_class.new } + + describe '#notion_client' do + let(:client_id) { 'test_notion_client_id' } + let(:client_secret) { 'test_notion_client_secret' } + + before do + allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil).and_return(client_id) + allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil).and_return(client_secret) + end + + it 'creates OAuth2 client with correct configuration' do + expect(OAuth2::Client).to receive(:new).with( + client_id, + client_secret, + { + site: 'https://api.notion.com', + authorize_url: 'https://api.notion.com/v1/oauth/authorize', + token_url: 'https://api.notion.com/v1/oauth/token', + auth_scheme: :basic_auth + } + ) + + controller.notion_client + end + + it 'loads client credentials from GlobalConfigService' do + expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil) + expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil) + + controller.notion_client + end + + it 'returns OAuth2::Client instance' do + client = controller.notion_client + expect(client).to be_an_instance_of(OAuth2::Client) + end + + it 'configures client with Notion-specific endpoints' do + client = controller.notion_client + expect(client.site).to eq('https://api.notion.com') + expect(client.options[:authorize_url]).to eq('https://api.notion.com/v1/oauth/authorize') + expect(client.options[:token_url]).to eq('https://api.notion.com/v1/oauth/token') + expect(client.options[:auth_scheme]).to eq(:basic_auth) + end + end +end diff --git a/spec/controllers/notion/callbacks_controller_spec.rb b/spec/controllers/notion/callbacks_controller_spec.rb new file mode 100644 index 000000000..045bbe396 --- /dev/null +++ b/spec/controllers/notion/callbacks_controller_spec.rb @@ -0,0 +1,112 @@ +require 'rails_helper' + +RSpec.describe Notion::CallbacksController, type: :request do + let(:account) { create(:account) } + let(:state) { account.to_sgid.to_s } + let(:oauth_code) { 'test_oauth_code' } + let(:notion_redirect_uri) { "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion" } + + let(:notion_response_body) do + { + 'access_token' => 'notion_access_token_123', + 'token_type' => 'bearer', + 'workspace_name' => 'Test Workspace', + 'workspace_id' => 'workspace_123', + 'workspace_icon' => 'https://notion.so/icon.png', + 'bot_id' => 'bot_123', + 'owner' => { + 'type' => 'user', + 'user' => { + 'id' => 'user_123', + 'name' => 'Test User' + } + } + } + end + + describe 'GET /notion/callback' do + before do + account.enable_features('notion_integration') + stub_const('ENV', ENV.to_hash.merge( + 'FRONTEND_URL' => 'http://localhost:3000', + 'NOTION_CLIENT_ID' => 'test_client_id', + 'NOTION_CLIENT_SECRET' => 'test_client_secret' + )) + + controller = described_class.new + allow(controller).to receive(:account).and_return(account) + allow(controller).to receive(:notion_redirect_uri).and_return(notion_redirect_uri) + allow(described_class).to receive(:new).and_return(controller) + end + + context 'when OAuth callback is successful' do + before do + stub_request(:post, 'https://api.notion.com/v1/oauth/token') + .to_return( + status: 200, + body: notion_response_body.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'creates a new integration hook' do + expect do + get '/notion/callback', params: { code: oauth_code, state: state } + end.to change(Integrations::Hook, :count).by(1) + + hook = Integrations::Hook.last + expect(hook.access_token).to eq('notion_access_token_123') + expect(hook.app_id).to eq('notion') + expect(hook.status).to eq('enabled') + end + + it 'sets correct hook attributes' do + get '/notion/callback', params: { code: oauth_code, state: state } + + hook = Integrations::Hook.last + expect(hook.account).to eq(account) + expect(hook.app_id).to eq('notion') + expect(hook.access_token).to eq('notion_access_token_123') + expect(hook.status).to eq('enabled') + end + + it 'stores notion workspace data in settings' do + get '/notion/callback', params: { code: oauth_code, state: state } + + hook = Integrations::Hook.last + expect(hook.settings['token_type']).to eq('bearer') + expect(hook.settings['workspace_name']).to eq('Test Workspace') + expect(hook.settings['workspace_id']).to eq('workspace_123') + expect(hook.settings['workspace_icon']).to eq('https://notion.so/icon.png') + expect(hook.settings['bot_id']).to eq('bot_123') + expect(hook.settings['owner']).to eq(notion_response_body['owner']) + end + + it 'handles successful callback and creates hook' do + get '/notion/callback', params: { code: oauth_code, state: state } + + # Due to controller mocking limitations in test, + # the redirect URL construction fails but hook creation succeeds + expect(Integrations::Hook.last.app_id).to eq('notion') + expect(response).to be_redirect + end + end + + context 'when OAuth token request fails' do + before do + stub_request(:post, 'https://api.notion.com/v1/oauth/token') + .to_return( + status: 400, + body: { error: 'invalid_grant' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'redirects to home page on error' do + get '/notion/callback', params: { code: oauth_code, state: state } + + expect(response).to redirect_to('/') + end + end + end +end From b7f3f72b9c2e171f37af252c30a19d29a80a03f0 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 27 Jun 2025 10:48:07 +0530 Subject: [PATCH 02/28] fix: Reply time calculation for re-opened conversations (#11787) This PR fixes the reply time calculation for reopened conversations. Previously, when a customer sent a message to reopen a resolved conversation, the reply time metric would be calculated incorrectly because the `waiting_since` timestamp was not properly set before the reply event was dispatched. This would create a case where you'd have reporting events like the following ``` [[33955732, "reply_time", 19.0], [33955847, "reply_time", 24.0], [33955666, "reply_time", 89.0], [33955530, "conversation_bot_handoff", 4.0], [33955567, "first_response", 42.0], [33955745, "reply_time", 21.0], [33955934, "reply_time", 49.0], [33955906, "reply_time", 121.0], [33987938, "conversation_resolved", 26285.0], [35571005, "reply_time", 985492.0]] ``` Note the `reply_time` after `conversation_resolved` The fix ensures that `waiting_since` is correctly updated when conversations are reopened, either through incoming messages or manual status changes, resulting in accurate reply time metrics that measure only the time from the customer's new message to the agent's response. ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? The changes have been tested with comprehensive specs that verify: 1. **Reply time calculation after conversation reopening** - Ensures correct timestamps are used when calculating reply times for reopened conversations 2. **Waiting since updates on status changes** - Verifies that `waiting_since` is properly set when conversation status changes from resolved to open 3. **Test the happy path** - Happy path is tested to ensure the `reply_time` and `first_response_time` is correctly calculated Test instructions: 1. Create a conversation with the last message from a customer and resolve it 2. Have an agent reopen it and reply to it 4. When an agent replies, verify that the agent reply_time event is not created for this message To fix any existing data, I've written a small script: https://gist.github.com/scmmishra/fdf458863f2d971978327bbfd5232d0c --------- Co-authored-by: Muhsin Keloth --- app/listeners/reporting_event_listener.rb | 4 + app/models/conversation.rb | 10 ++ .../reporting_event_listener_spec.rb | 110 +++++++++++++++++ spec/models/conversation_spec.rb | 113 ++++++++++++++++++ 4 files changed, 237 insertions(+) diff --git a/app/listeners/reporting_event_listener.rb b/app/listeners/reporting_event_listener.rb index b31d899bb..9f22fe8de 100644 --- a/app/listeners/reporting_event_listener.rb +++ b/app/listeners/reporting_event_listener.rb @@ -47,6 +47,10 @@ class ReportingEventListener < BaseListener message = extract_message_and_account(event)[0] conversation = message.conversation waiting_since = event.data[:waiting_since] + + return if waiting_since.blank? + + # When waiting_since is nil, set reply_time to 0 reply_time = message.created_at.to_i - waiting_since.to_i reporting_event = ReportingEvent.new( diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 922118b09..d6c5d0e4a 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -198,11 +198,21 @@ class Conversation < ApplicationRecord private def execute_after_update_commit_callbacks + handle_resolved_status_change notify_status_change create_activity notify_conversation_updation end + def handle_resolved_status_change + # When conversation is resolved, clear waiting_since using update_column to avoid callbacks + return unless saved_change_to_status? && status == 'resolved' + + # rubocop:disable Rails/SkipsModelValidations + update_column(:waiting_since, nil) + # rubocop:enable Rails/SkipsModelValidations + end + def ensure_snooze_until_reset self.snoozed_until = nil unless snoozed? end diff --git a/spec/listeners/reporting_event_listener_spec.rb b/spec/listeners/reporting_event_listener_spec.rb index 9a6b8d123..0edf79556 100644 --- a/spec/listeners/reporting_event_listener_spec.rb +++ b/spec/listeners/reporting_event_listener_spec.rb @@ -66,6 +66,34 @@ describe ReportingEventListener do end describe '#reply_created' do + let(:contact) { create(:contact, account: account) } + + def create_customer_message(conversation, created_at: Time.current) + create(:message, + message_type: 'incoming', + account: account, + inbox: inbox, + conversation: conversation, + sender: contact, + created_at: created_at) + end + + def create_agent_message(conversation, created_at: Time.current, sender: user) + create(:message, + message_type: 'outgoing', + account: account, + inbox: inbox, + conversation: conversation, + sender: sender, + created_at: created_at) + end + + def create_reply_event(agent_message, waiting_since, event_time = nil) + Events::Base.new('reply.created', event_time || agent_message.created_at, + waiting_since: waiting_since, + message: agent_message) + end + it 'creates reply created event' do event = Events::Base.new('reply.created', Time.zone.now, waiting_since: 2.hours.ago, message: message) listener.reply_created(event) @@ -74,6 +102,88 @@ describe ReportingEventListener do expect(events.length).to be 1 expect(events.first.value).to be_within(1).of(7200) end + + context 'when conversation is reopened' do + let(:resolved_conversation) do + create(:conversation, account: account, inbox: inbox, assignee: user, + status: 'resolved', contact: contact) + end + + context 'when customer sends message after resolution' do + it 'calculates reply time from the reopening message' do + customer_message_time = 3.hours.ago + create_customer_message(resolved_conversation, created_at: customer_message_time) + + resolved_conversation.reload + expect(resolved_conversation.status).to eq('open') + + agent_reply_time = 1.hour.ago + agent_message = create_agent_message(resolved_conversation, created_at: agent_reply_time) + + event = create_reply_event(agent_message, customer_message_time) + listener.reply_created(event) + + events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id) + expect(events.length).to be 1 + expect(events.first.value).to be_within(60).of(7200) + end + end + + context 'when conversation has multiple reopenings' do + it 'tracks reply time correctly for each reopening' do + create_customer_message(resolved_conversation, created_at: 5.hours.ago) + first_agent_reply = create_agent_message(resolved_conversation, created_at: 4.hours.ago) + + event = create_reply_event(first_agent_reply, 5.hours.ago) + listener.reply_created(event) + + resolved_conversation.update!(status: 'resolved') + + create_customer_message(resolved_conversation, created_at: 2.hours.ago) + second_agent_reply = create_agent_message(resolved_conversation, created_at: 1.5.hours.ago) + + event = create_reply_event(second_agent_reply, 2.hours.ago) + listener.reply_created(event) + + events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id) + .order(created_at: :asc) + expect(events.length).to be 2 + expect(events.first.value).to be_within(60).of(3600) + expect(events.second.value).to be_within(60).of(1800) + end + end + + context 'when conversation is manually reopened' do + it 'sets waiting_since when first customer message arrives after manual reopening' do + resolved_conversation.update!(status: 'open') + + customer_message_time = 1.hour.ago + create_customer_message(resolved_conversation, created_at: customer_message_time) + + agent_reply_time = 15.minutes.ago + agent_message = create_agent_message(resolved_conversation, created_at: agent_reply_time) + + event = create_reply_event(agent_message, customer_message_time) + listener.reply_created(event) + + events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id) + expect(events.length).to be 1 + expect(events.first.value).to be_within(60).of(2700) + end + end + + context 'when waiting_since is nil' do + it 'does not creates reply time events' do + agent_message = create_agent_message(resolved_conversation) + + event = create_reply_event(agent_message, nil) + listener.reply_created(event) + + events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id) + expect(events.length).to be 0 + end + end + end end describe '#first_reply_created' do diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index aef91603d..a29359528 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -836,4 +836,117 @@ RSpec.describe Conversation do expect(message_window_service).to have_received(:can_reply?) end end + + describe 'reply time calculation flows' do + include ActiveJob::TestHelper + + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, assignee: agent, waiting_since: nil) } + let(:conversation_start_time) { 5.hours.ago } + + before do + create(:inbox_member, user: agent, inbox: inbox) + # rubocop:disable Rails/SkipsModelValidations + conversation.update_column(:waiting_since, nil) + conversation.update_column(:created_at, conversation_start_time) + # rubocop:enable Rails/SkipsModelValidations + conversation.messages.destroy_all + conversation.reporting_events.destroy_all + conversation.reload + end + + def create_customer_message(conversation, created_at: Time.current) + message = nil + perform_enqueued_jobs do + message = create(:message, + message_type: 'incoming', + account: conversation.account, + inbox: conversation.inbox, + conversation: conversation, + sender: conversation.contact, + created_at: created_at) + end + message + end + + def create_agent_message(conversation, created_at: Time.current) + message = nil + perform_enqueued_jobs do + message = create(:message, + message_type: 'outgoing', + account: conversation.account, + inbox: conversation.inbox, + conversation: conversation, + sender: conversation.assignee, + created_at: created_at) + end + message + end + + it 'correctly tracks waiting_since and creates first response time events' do + create_customer_message(conversation, created_at: conversation_start_time) + conversation.reload + expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time) + + # Agent replies - this should create first response event + agent_reply1_time = 4.hours.ago + create_agent_message(conversation, created_at: agent_reply1_time) + + first_response_events = account.reporting_events.where(name: 'first_response', conversation_id: conversation.id) + expect(first_response_events.count).to eq(1) + expect(first_response_events.first.value).to be_within(1.second).of(1.hour) + + # the first response should also clear the waiting_since + conversation.reload + expect(conversation.waiting_since).to be_nil + end + + it 'does not reset waiting_since if customer sends another message' do + create_customer_message(conversation, created_at: conversation_start_time) + conversation.reload + expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time) + + create_customer_message(conversation, created_at: 3.hours.ago) + conversation.reload + expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time) + end + + it 'records the correct reply_time for subsequent messages' do + create_customer_message(conversation, created_at: conversation_start_time) + create_agent_message(conversation, created_at: 4.hours.ago) + create_customer_message(conversation, created_at: 3.hours.ago) + + create_agent_message(conversation, created_at: 2.hours.ago) + reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id) + expect(reply_events.count).to eq(1) + expect(reply_events.first.value).to be_within(1.second).of(1.hour) + + conversation.reload + expect(conversation.waiting_since).to be_nil + end + + it 'records zero reply time if an agent sends a message after resolution' do + create_customer_message(conversation, created_at: conversation_start_time) + create_agent_message(conversation, created_at: 4.hours.ago) + create_customer_message(conversation, created_at: 3.hours.ago) + + conversation.toggle_status + expect(conversation.status).to eq('resolved') + + conversation.toggle_status + expect(conversation.status).to eq('open') + + conversation.reload + expect(conversation.waiting_since).to be_nil + + create_agent_message(conversation, created_at: 1.hour.ago) + # update_waiting_since will ensure that no events were created since the waiting_since was nil + # if the event is created it should log zero value, we have handled that in the reporting_event_listener + reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id) + expect(reply_events.count).to eq(0) + end + end end From eea1ab3002760077fde6494a516e7482bf00aa6d Mon Sep 17 00:00:00 2001 From: Pranav Date: Fri, 27 Jun 2025 15:48:04 -0700 Subject: [PATCH 03/28] fix: Add composite index on messages for csat_metrics API performance (#11831) This PR adds a composite index (:account_id, :content_type, :created_at) on the table messages. This index is added as a temporary fix for performance issues in the CSAT responses controller where we query messages with account_id, content_type and created_at. The current implementation (account.message.input_csat.count) times out with millions of messages. TODO: Create a dedicated csat_survey table and add entries when surveys are sent, then query this table instead of the entire messages table for better performance. --- app/models/message.rb | 1 + .../20250627195529_add_index_to_messages.rb | 20 +++++++++++++++++++ db/schema.rb | 3 ++- 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20250627195529_add_index_to_messages.rb diff --git a/app/models/message.rb b/app/models/message.rb index e7c4e9b6c..d4036416e 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -24,6 +24,7 @@ # # Indexes # +# idx_messages_account_content_created (account_id,content_type,created_at) # index_messages_on_account_created_type (account_id,created_at,message_type) # index_messages_on_account_id (account_id) # index_messages_on_account_id_and_inbox_id (account_id,inbox_id) diff --git a/db/migrate/20250627195529_add_index_to_messages.rb b/db/migrate/20250627195529_add_index_to_messages.rb new file mode 100644 index 000000000..ff58c31d4 --- /dev/null +++ b/db/migrate/20250627195529_add_index_to_messages.rb @@ -0,0 +1,20 @@ +class AddIndexToMessages < ActiveRecord::Migration[7.0] + def change + # This index is added as a temporary fix for performance issues in the CSAT + # responses controller where we query messages with account_id, content_type + # and created_at. The current implementation (account.message.input_csat.count) + # times out with millions of messages. + # + # TODO: Create a dedicated csat_survey table and add entries when surveys are + # sent, then query this table instead of the entire messages table for better + # performance. + return if index_exists?( + :messages, + [:account_id, :content_type, :created_at], + name: 'idx_messages_account_content_created' + ) + + add_index :messages, [:account_id, :content_type, :created_at], + name: 'idx_messages_account_content_created', algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index fdd0cce82..af0c89086 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.1].define(version: 2025_06_20_120000) do +ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -825,6 +825,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_06_20_120000) do t.text "processed_message_content" t.jsonb "sentiment", default: {} t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin + t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created" t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type" t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id" t.index ["account_id"], name: "index_messages_on_account_id" From ee4a0d448622e9cf552e3cc6477723ff3157cc6a Mon Sep 17 00:00:00 2001 From: Pranav Date: Fri, 27 Jun 2025 16:58:50 -0700 Subject: [PATCH 04/28] fix: disable_ddl_transaction! on add_index action (#11833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All migrations will automatically be wrapped in a transaction. There are queries that you can’t execute inside a transaction. Adding index concurrently is one of them, we have to disable the transaction. I missed this in the earlier PR. #11831 --- db/migrate/20250627195529_add_index_to_messages.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/db/migrate/20250627195529_add_index_to_messages.rb b/db/migrate/20250627195529_add_index_to_messages.rb index ff58c31d4..eb6b95cdd 100644 --- a/db/migrate/20250627195529_add_index_to_messages.rb +++ b/db/migrate/20250627195529_add_index_to_messages.rb @@ -1,4 +1,6 @@ class AddIndexToMessages < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + def change # This index is added as a temporary fix for performance issues in the CSAT # responses controller where we query messages with account_id, content_type From b1893c7d96c41485b7b6af45609983e9d131c6a8 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 30 Jun 2025 11:35:32 +0530 Subject: [PATCH 05/28] fix: Support location messages in Twilio WhatsApp integration (#11830) Fixes location messages not appearing in conversations when sent via Twilio. Location messages were being filtered out due to empty body content and missing parameter handling. ![CleanShot 2025-06-27 at 20 48 12](https://github.com/user-attachments/assets/b5a75796-6937-49bc-b689-7d04f4ea5d09) --- app/controllers/twilio/callback_controller.rb | 5 ++++- app/jobs/webhooks/twilio_events_job.rb | 10 +++++++-- .../twilio/incoming_message_service.rb | 14 ++++++++++++ spec/jobs/webhooks/twilio_events_job_spec.rb | 21 ++++++++++++++++++ .../twilio/incoming_message_service_spec.rb | 22 +++++++++++++++++++ 5 files changed, 69 insertions(+), 3 deletions(-) diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index ff16e9386..455828228 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -27,7 +27,10 @@ class Twilio::CallbackController < ApplicationController *Array.new(10) { |i| :"MediaUrl#{i}" }, *Array.new(10) { |i| :"MediaContentType#{i}" }, :MessagingServiceSid, - :NumMedia + :NumMedia, + :Latitude, + :Longitude, + :MessageType ) end end diff --git a/app/jobs/webhooks/twilio_events_job.rb b/app/jobs/webhooks/twilio_events_job.rb index 5f44d981b..87fdfadfb 100644 --- a/app/jobs/webhooks/twilio_events_job.rb +++ b/app/jobs/webhooks/twilio_events_job.rb @@ -2,10 +2,16 @@ class Webhooks::TwilioEventsJob < ApplicationJob queue_as :low def perform(params = {}) - # Skip processing if Body parameter or MediaUrl0 is not present + # Skip processing if Body parameter, MediaUrl0, or location data is not present # This is to skip processing delivery events being delivered to this endpoint - return if params[:Body].blank? && params[:MediaUrl0].blank? + return if params[:Body].blank? && params[:MediaUrl0].blank? && !valid_location_message?(params) ::Twilio::IncomingMessageService.new(params: params).perform end + + private + + def valid_location_message?(params) + params[:MessageType] == 'location' && params[:Latitude].present? && params[:Longitude].present? + end end diff --git a/app/services/twilio/incoming_message_service.rb b/app/services/twilio/incoming_message_service.rb index c38577599..a11b99744 100644 --- a/app/services/twilio/incoming_message_service.rb +++ b/app/services/twilio/incoming_message_service.rb @@ -17,6 +17,7 @@ class Twilio::IncomingMessageService source_id: params[:SmsSid] ) attach_files + attach_location if location_message? @message.save! end @@ -155,4 +156,17 @@ class Twilio::IncomingMessageService Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping" nil end + + def location_message? + params[:MessageType] == 'location' && params[:Latitude].present? && params[:Longitude].present? + end + + def attach_location + @message.attachments.new( + account_id: @message.account_id, + file_type: :location, + coordinates_lat: params[:Latitude].to_f, + coordinates_long: params[:Longitude].to_f + ) + end end diff --git a/spec/jobs/webhooks/twilio_events_job_spec.rb b/spec/jobs/webhooks/twilio_events_job_spec.rb index f42caf675..aad70c68b 100644 --- a/spec/jobs/webhooks/twilio_events_job_spec.rb +++ b/spec/jobs/webhooks/twilio_events_job_spec.rb @@ -79,4 +79,25 @@ RSpec.describe Webhooks::TwilioEventsJob do described_class.perform_now(params_with_media) end end + + context 'when location message is present' do + let(:params_with_location) do + { + From: 'whatsapp:+1234567890', + To: 'whatsapp:+0987654321', + MessageType: 'location', + Latitude: '12.160894393921', + Longitude: '75.265205383301', + AccountSid: 'AC123', + SmsSid: 'SM123' + } + end + + it 'processes the location message' do + service = double + expect(Twilio::IncomingMessageService).to receive(:new).with(params: params_with_location).and_return(service) + expect(service).to receive(:perform) + described_class.perform_now(params_with_location) + end + end end diff --git a/spec/services/twilio/incoming_message_service_spec.rb b/spec/services/twilio/incoming_message_service_spec.rb index c8812e45d..94fc3fdbf 100644 --- a/spec/services/twilio/incoming_message_service_spec.rb +++ b/spec/services/twilio/incoming_message_service_spec.rb @@ -260,5 +260,27 @@ describe Twilio::IncomingMessageService do expect(conversation.reload.messages.last.attachments.map(&:file_type)).to contain_exactly('image', 'image') end end + + context 'when a location message is received' do + let(:params_with_location) do + { + SmsSid: 'SMxx', + From: '+12345', + AccountSid: 'ACxxx', + MessagingServiceSid: twilio_channel.messaging_service_sid, + MessageType: 'location', + Latitude: '12.160894393921', + Longitude: '75.265205383301' + } + end + + it 'creates a message with location attachment' do + described_class.new(params: params_with_location).perform + + message = conversation.reload.messages.last + expect(message.attachments.count).to eq(1) + expect(message.attachments.first.file_type).to eq('location') + end + end end end From d7c10b4f2af568857d25e730a267c0ca352abb66 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:30:09 +0530 Subject: [PATCH 06/28] chore: Add "Coming Soon" overlay to voice channel selector (#11835) # Pull Request Template ### Screenshots **Dark** image **Light** image --------- Co-authored-by: Muhsin Keloth --- .eslintrc.js | 1 + .../dashboard/components/ChannelSelector.vue | 21 ++++++++++++++----- .../components/widgets/ChannelItem.vue | 7 +++++++ .../dashboard/i18n/locale/en/components.json | 3 +++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 6c867f557..6b5205ad7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -103,6 +103,7 @@ module.exports = { '⌘', '📄', '🎉', + '🚀', '💬', '👥', '📥', diff --git a/app/javascript/dashboard/components/ChannelSelector.vue b/app/javascript/dashboard/components/ChannelSelector.vue index 1e09f5363..236ae3ded 100644 --- a/app/javascript/dashboard/components/ChannelSelector.vue +++ b/app/javascript/dashboard/components/ChannelSelector.vue @@ -9,20 +9,31 @@ export default { type: String, required: true, }, + isComingSoon: { + type: Boolean, + default: false, + }, }, }; @@ -33,7 +44,7 @@ export default { } &:hover { - @apply border-transparent shadow-none cursor-not-allowed; + @apply border-n-strong shadow-none cursor-not-allowed; } } diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 933e117a9..5bc96d2b5 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -57,6 +57,12 @@ export default { 'voice', ].includes(key); }, + isComingSoon() { + const { key } = this.channel; + // Show "Coming Soon" only if the channel is marked as coming soon + // and the corresponding feature flag is not enabled yet. + return ['voice'].includes(key) && !this.isActive; + }, }, methods: { getChannelThumbnail() { @@ -79,6 +85,7 @@ export default { :class="{ inactive: !isActive }" :title="channel.name" :src="getChannelThumbnail()" + :is-coming-soon="isComingSoon" @click="onItemClick" /> diff --git a/app/javascript/dashboard/i18n/locale/en/components.json b/app/javascript/dashboard/i18n/locale/en/components.json index e44c6e039..0a2542a84 100644 --- a/app/javascript/dashboard/i18n/locale/en/components.json +++ b/app/javascript/dashboard/i18n/locale/en/components.json @@ -49,5 +49,8 @@ "HOURS": "Hours", "DAYS": "Days", "PLACEHOLDER": "Enter duration" + }, + "CHANNEL_SELECTOR": { + "COMING_SOON": "Coming Soon!" } } From 6e207acb5aee4971b320737b12bea5ee7914b046 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 30 Jun 2025 20:54:19 +0530 Subject: [PATCH 07/28] fix: CSAT table header and date range translation issue on reload (#11836) # Pull Request Template ## Description This PR fixes the translation issue in the CSAT reports table header and date range filter, where labels reverted to English after a page reload. Fixes https://linear.app/chatwoot/issue/CW-4557/language-switching-on-page-reload ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? https://github.com/user-attachments/assets/c68da978-1f17-44b5-bb21-5ea2668563fb ## 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 --- .../settings/reports/components/CsatTable.vue | 8 ++-- .../reports/components/Filters/DateRange.vue | 45 ++++++++++--------- .../specs/Filters/FiltersDateRange.spec.js | 23 +++++----- 3 files changed, 40 insertions(+), 36 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue index 8beeafd70..76a3e3079 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue @@ -61,7 +61,7 @@ const defaultSpanRender = cellProps => { const columnHelper = createColumnHelper(); -const columns = [ +const columns = computed(() => [ columnHelper.accessor('contact', { header: t('CSAT_REPORTS.TABLE.HEADER.CONTACT_NAME'), width: 200, @@ -121,7 +121,7 @@ const columns = [ width: 100, cell: cellProps => h(ConversationCell, cellProps), }), -]; +]); const paginationParams = computed(() => { return { @@ -134,7 +134,9 @@ const table = useVueTable({ get data() { return tableData.value; }, - columns, + get columns() { + return columns.value; + }, manualPagination: true, enableSorting: false, getCoreRowModel: getCoreRowModel(), diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateRange.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateRange.vue index 4d1cc340f..9caab26dd 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateRange.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/Filters/DateRange.vue @@ -1,28 +1,33 @@ - @@ -31,7 +36,7 @@ export default { { - it('emits "on-range-change" event when updateRange is called', () => { + it('emits "onRangeChange" event when updateRange is called', () => { const wrapper = shallowMount(ReportFiltersDateRange, mountParams); const selectedRange = DATE_RANGE_OPTIONS.LAST_7_DAYS; wrapper.vm.updateRange(selectedRange); - expect(wrapper.emitted('on-range-change')).toBeTruthy(); - expect(wrapper.emitted('on-range-change')[0]).toEqual([selectedRange]); + expect(wrapper.emitted('onRangeChange')).toBeTruthy(); + expect(wrapper.emitted('onRangeChange')[0]).toEqual([selectedRange]); }); it('initializes options correctly', () => { const wrapper = shallowMount(ReportFiltersDateRange, mountParams); - const expectedOptions = Object.values(DATE_RANGE_OPTIONS).map(option => ({ - ...option, - name: option.translationKey, - })); + const expectedIds = Object.values(DATE_RANGE_OPTIONS).map( + option => option.id + ); + const receivedIds = wrapper.vm.options.map(option => option.id); - expect(wrapper.vm.options).toEqual(expectedOptions); + expect(receivedIds).toEqual(expectedIds); }); it('initializes selectedOption correctly', () => { const wrapper = shallowMount(ReportFiltersDateRange, mountParams); - const expectedSelectedOption = Object.values(DATE_RANGE_OPTIONS)[0]; - expect(wrapper.vm.selectedOption).toEqual({ - ...expectedSelectedOption, - name: expectedSelectedOption.translationKey, - }); + const expectedId = Object.values(DATE_RANGE_OPTIONS)[0].id; + expect(wrapper.vm.selectedOption.id).toBe(expectedId); }); }); From a657b45bd1bb9e8ced580ad4a0de4adc77f9510e Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 1 Jul 2025 00:12:07 +0530 Subject: [PATCH 08/28] feat(revert): "feat: captain image support" (#11841) Reverts chatwoot/chatwoot#11730 --- .../accounts/captain/assistants_controller.rb | 4 +- .../conversation/response_builder_job.rb | 37 ++- .../captain/llm/assistant_chat_service.rb | 13 +- .../open_ai_message_builder_service.rb | 59 ---- .../captain/assistants_controller_spec.rb | 8 +- .../conversation/response_builder_job_spec.rb | 25 -- .../open_ai_message_builder_service_spec.rb | 309 ------------------ 7 files changed, 37 insertions(+), 418 deletions(-) delete mode 100644 enterprise/app/services/captain/open_ai_message_builder_service.rb delete mode 100644 spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb index ec8e8e653..e5a055836 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb @@ -25,8 +25,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base def playground response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response( - additional_message: params[:message_content], - message_history: message_history + params[:message_content], + message_history ) render json: response diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index 431945896..f341a6e98 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -26,7 +26,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob def generate_and_process_response @response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response( - message_history: collect_previous_messages + @conversation.messages.incoming.last.content, + collect_previous_messages ) return process_action('handoff') if handoff_requested? @@ -42,11 +43,33 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob .where(message_type: [:incoming, :outgoing]) .where(private: false) .map do |message| - { - content: prepare_multimodal_message_content(message), - role: determine_role(message) - } + { + content: message_content(message), + role: determine_role(message) + } + end + end + + def message_content(message) + return message.content if message.content.present? + return 'User has shared a message without content' unless message.attachments.any? + + audio_transcriptions = extract_audio_transcriptions(message.attachments) + return audio_transcriptions if audio_transcriptions.present? + + 'User has shared an attachment' + end + + def extract_audio_transcriptions(attachments) + audio_attachments = attachments.where(file_type: :audio) + return '' if audio_attachments.blank? + + transcriptions = '' + audio_attachments.each do |attachment| + result = Messages::AudioTranscriptionService.new(attachment).perform + transcriptions += result[:transcriptions] if result[:success] end + transcriptions end def determine_role(message) @@ -55,10 +78,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob message.message_type == 'incoming' ? 'user' : 'system' end - def prepare_multimodal_message_content(message) - Captain::OpenAiMessageBuilderService.new(message: message).generate_content - end - def handoff_requested? @response['response'] == 'conversation_handoff' end diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb index ca8fafaa0..569931d44 100644 --- a/enterprise/app/services/captain/llm/assistant_chat_service.rb +++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb @@ -12,16 +12,9 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService register_tools end - # additional_message: A single message (String) from the user that should be appended to the chat. - # It can be an empty String or nil when you only want to supply historical messages. - # message_history: An Array of already formatted messages that provide the previous context. - # role: The role for the additional_message (defaults to `user`). - # - # NOTE: Parameters are provided as keyword arguments to improve clarity and avoid relying on - # positional ordering. - def generate_response(additional_message: nil, message_history: [], role: 'user') - @messages += message_history - @messages << { role: role, content: additional_message } if additional_message.present? + def generate_response(input, previous_messages = [], role = 'user') + @messages += previous_messages + @messages << { role: role, content: input } if input.present? request_chat_completion end diff --git a/enterprise/app/services/captain/open_ai_message_builder_service.rb b/enterprise/app/services/captain/open_ai_message_builder_service.rb deleted file mode 100644 index 3320ad537..000000000 --- a/enterprise/app/services/captain/open_ai_message_builder_service.rb +++ /dev/null @@ -1,59 +0,0 @@ -class Captain::OpenAiMessageBuilderService - pattr_initialize [:message!] - - def generate_content - parts = [] - parts << text_part(@message.content) if @message.content.present? - parts.concat(attachment_parts(@message.attachments)) if @message.attachments.any? - - return 'Message without content' if parts.blank? - return parts.first[:text] if parts.one? && parts.first[:type] == 'text' - - parts - end - - private - - def text_part(text) - { type: 'text', text: text } - end - - def image_part(image_url) - { type: 'image_url', image_url: { url: image_url } } - end - - def attachment_parts(attachments) - image_attachments = attachments.where(file_type: :image) - image_content = image_parts(image_attachments) - - transcription = extract_audio_transcriptions(attachments) - transcription_part = text_part(transcription) if transcription.present? - - attachment_part = text_part('User has shared an attachment') if attachments.where.not(file_type: %i[image audio]).exists? - - [image_content, transcription_part, attachment_part].flatten.compact - end - - def image_parts(image_attachments) - image_attachments.each_with_object([]) do |attachment, parts| - url = get_attachment_url(attachment) - parts << image_part(url) if url.present? - end - end - - def get_attachment_url(attachment) - return attachment.external_url if attachment.external_url.present? - - attachment.file.attached? ? attachment.file_url : nil - end - - def extract_audio_transcriptions(attachments) - audio_attachments = attachments.where(file_type: :audio) - return '' if audio_attachments.blank? - - audio_attachments.map do |attachment| - result = Messages::AudioTranscriptionService.new(attachment).perform - result[:success] ? result[:transcriptions] : '' - end.join - end -end \ No newline at end of file diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb index 80be6f30f..1f6d83d80 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb @@ -211,8 +211,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do expect(response).to have_http_status(:success) expect(chat_service).to have_received(:generate_response).with( - additional_message: valid_params[:message_content], - message_history: valid_params[:message_history] + valid_params[:message_content], + valid_params[:message_history] ) expect(json_response[:content]).to eq('Assistant response') end @@ -232,8 +232,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do expect(response).to have_http_status(:success) expect(chat_service).to have_received(:generate_response).with( - additional_message: params_without_history[:message_content], - message_history: [] + params_without_history[:message_content], + [] ) end end diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb index ca8d4a6c0..1e4a6e824 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -30,30 +30,5 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do account.reload expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1) end - - context 'when message contains an image' do - let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') } - let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') } - - before do - image_attachment - end - - it 'includes image URL directly in the message content for OpenAI vision analysis' do - # Expect the generate_response to receive multimodal content with image URL - expect(mock_llm_chat_service).to receive(:generate_response) do |**kwargs| - history = kwargs[:message_history] - last_entry = history.last - expect(last_entry[:content]).to be_an(Array) - expect(last_entry[:content].any? { |part| part[:type] == 'text' && part[:text] == 'Can you help with this error?' }).to be true - expect(last_entry[:content].any? do |part| - part[:type] == 'image_url' && part[:image_url][:url] == 'https://example.com/error.jpg' - end).to be true - { 'response' => 'I can see the error in your image. It appears to be a database connection issue.' } - end - - described_class.perform_now(conversation, assistant) - end - end end end diff --git a/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb b/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb deleted file mode 100644 index 13c29f756..000000000 --- a/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb +++ /dev/null @@ -1,309 +0,0 @@ -require 'rails_helper' - -RSpec.describe Captain::OpenAiMessageBuilderService do - subject(:service) { described_class.new(message: message) } - - let(:message) { create(:message, content: 'Hello world') } - - describe '#generate_content' do - context 'when message has only text content' do - it 'returns the text content directly' do - expect(service.generate_content).to eq('Hello world') - end - end - - context 'when message has no content and no attachments' do - let(:message) { create(:message, content: nil) } - - it 'returns default message' do - expect(service.generate_content).to eq('Message without content') - end - end - - context 'when message has text content and attachments' do - before do - attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg') - attachment.save! - end - - it 'returns an array of content parts' do - result = service.generate_content - expect(result).to be_an(Array) - expect(result).to include({ type: 'text', text: 'Hello world' }) - expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }) - end - end - - context 'when message has only non-text attachments' do - let(:message) { create(:message, content: nil) } - - before do - attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg') - attachment.save! - end - - it 'returns an array of content parts without text' do - result = service.generate_content - expect(result).to be_an(Array) - expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }) - expect(result).not_to include(hash_including(type: 'text', text: 'Hello world')) - end - end - end - - describe '#attachment_parts' do - let(:message) { create(:message, content: nil) } - let(:attachments) { message.attachments } - - context 'with image attachments' do - before do - attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg') - attachment.save! - end - - it 'includes image parts' do - result = service.send(:attachment_parts, attachments) - expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }) - end - end - - context 'with audio attachments' do - let(:audio_attachment) do - attachment = message.attachments.build(account_id: message.account_id, file_type: :audio) - attachment.save! - attachment - end - - before do - allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return( - instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio transcription text' }) - ) - end - - it 'includes transcription text part' do - audio_attachment # trigger creation - result = service.send(:attachment_parts, attachments) - expect(result).to include({ type: 'text', text: 'Audio transcription text' }) - end - end - - context 'with other file types' do - before do - attachment = message.attachments.build(account_id: message.account_id, file_type: :file) - attachment.save! - end - - it 'includes generic attachment message' do - result = service.send(:attachment_parts, attachments) - expect(result).to include({ type: 'text', text: 'User has shared an attachment' }) - end - end - - context 'with mixed attachment types' do - let(:image_attachment) do - attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg') - attachment.save! - attachment - end - - let(:audio_attachment) do - attachment = message.attachments.build(account_id: message.account_id, file_type: :audio) - attachment.save! - attachment - end - - let(:document_attachment) do - attachment = message.attachments.build(account_id: message.account_id, file_type: :file) - attachment.save! - attachment - end - - before do - allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return( - instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio text' }) - ) - end - - it 'includes all relevant parts' do - image_attachment # trigger creation - audio_attachment # trigger creation - document_attachment # trigger creation - - result = service.send(:attachment_parts, attachments) - expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }) - expect(result).to include({ type: 'text', text: 'Audio text' }) - expect(result).to include({ type: 'text', text: 'User has shared an attachment' }) - end - end - end - - describe '#image_parts' do - let(:message) { create(:message, content: nil) } - - context 'with valid image attachments' do - let(:image1) do - attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image1.jpg') - attachment.save! - attachment - end - - let(:image2) do - attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image2.jpg') - attachment.save! - attachment - end - - it 'returns image parts for all valid images' do - image1 # trigger creation - image2 # trigger creation - - image_attachments = message.attachments.where(file_type: :image) - result = service.send(:image_parts, image_attachments) - - expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image1.jpg' } }) - expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image2.jpg' } }) - end - end - - context 'with image attachments without URLs' do - let(:image_attachment) do - attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: nil) - attachment.save! - attachment - end - - before do - allow(image_attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false)) - end - - it 'skips images without valid URLs' do - image_attachment # trigger creation - - image_attachments = message.attachments.where(file_type: :image) - result = service.send(:image_parts, image_attachments) - - expect(result).to be_empty - end - end - end - - describe '#get_attachment_url' do - let(:attachment) do - attachment = message.attachments.build(account_id: message.account_id, file_type: :image) - attachment.save! - attachment - end - - context 'when attachment has external_url' do - before { attachment.update(external_url: 'https://example.com/image.jpg') } - - it 'returns external_url' do - expect(service.send(:get_attachment_url, attachment)).to eq('https://example.com/image.jpg') - end - end - - context 'when attachment has attached file' do - before do - attachment.update(external_url: nil) - allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: true)) - allow(attachment).to receive(:file_url).and_return('https://local.com/file.jpg') - end - - it 'returns file_url' do - expect(service.send(:get_attachment_url, attachment)).to eq('https://local.com/file.jpg') - end - end - - context 'when attachment has no URL or file' do - before do - attachment.update(external_url: nil) - allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false)) - end - - it 'returns nil' do - expect(service.send(:get_attachment_url, attachment)).to be_nil - end - end - end - - describe '#extract_audio_transcriptions' do - let(:message) { create(:message, content: nil) } - - context 'with no audio attachments' do - it 'returns empty string' do - result = service.send(:extract_audio_transcriptions, message.attachments) - expect(result).to eq('') - end - end - - context 'with successful audio transcriptions' do - let(:audio1) do - attachment = message.attachments.build(account_id: message.account_id, file_type: :audio) - attachment.save! - attachment - end - - let(:audio2) do - attachment = message.attachments.build(account_id: message.account_id, file_type: :audio) - attachment.save! - attachment - end - - before do - allow(Messages::AudioTranscriptionService).to receive(:new).with(audio1).and_return( - instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'First audio text. ' }) - ) - allow(Messages::AudioTranscriptionService).to receive(:new).with(audio2).and_return( - instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Second audio text.' }) - ) - end - - it 'concatenates all successful transcriptions' do - audio1 # trigger creation - audio2 # trigger creation - - attachments = message.attachments - result = service.send(:extract_audio_transcriptions, attachments) - expect(result).to eq('First audio text. Second audio text.') - end - end - - context 'with failed audio transcriptions' do - let(:audio_attachment) do - attachment = message.attachments.build(account_id: message.account_id, file_type: :audio) - attachment.save! - attachment - end - - before do - allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return( - instance_double(Messages::AudioTranscriptionService, perform: { success: false, transcriptions: nil }) - ) - end - - it 'returns empty string for failed transcriptions' do - audio_attachment # trigger creation - - attachments = message.attachments - result = service.send(:extract_audio_transcriptions, attachments) - expect(result).to eq('') - end - end - end - - describe 'private helper methods' do - describe '#text_part' do - it 'returns correct text part format' do - result = service.send(:text_part, 'Hello world') - expect(result).to eq({ type: 'text', text: 'Hello world' }) - end - end - - describe '#image_part' do - it 'returns correct image part format' do - result = service.send(:image_part, 'https://example.com/image.jpg') - expect(result).to eq({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }) - end - end - end -end From 58da92a252bd03437b42b1c2d281bb4cb0b2164e Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 30 Jun 2025 19:06:25 -0700 Subject: [PATCH 09/28] chore: Disable copilot usage after the response count is over (#11845) Disable copilot if the response usage is over. --- config/locales/en.yml | 1 + .../captain/copilot_threads_controller.rb | 15 ++++++++-- .../copilot_threads_controller_spec.rb | 30 ++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index a41a009cf..221f83472 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -270,6 +270,7 @@ en: short_description: 'Sync your contacts and conversations with LeadSquared CRM.' description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: + copilot_message_required: Message is required copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' copilot: 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 index 533859b95..8ea85145e 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb @@ -23,14 +23,25 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts:: message: { content: copilot_thread_params[:message] } ) - copilot_message.enqueue_response_job(copilot_thread_params[:conversation_id], Current.user.id) + build_copilot_response(copilot_message) end end private + def build_copilot_response(copilot_message) + if Current.account.usage_limits[:captain][:responses][:current_available].positive? + copilot_message.enqueue_response_job(copilot_thread_params[:conversation_id], Current.user.id) + else + copilot_message.copilot_thread.copilot_messages.create!( + message_type: :assistant, + message: { content: I18n.t('captain.copilot_limit') } + ) + end + end + def ensure_message - return render_could_not_create_error('Message is required') if copilot_thread_params[:message].blank? + return render_could_not_create_error(I18n.t('captain.copilot_message_required')) if copilot_thread_params[:message].blank? end def assistant 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 index b8fc628d1..8b561f22a 100644 --- 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 @@ -86,7 +86,28 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do end context 'with valid params' do + it 'returns error when usage limit is exceeded' do + account.limits = { captain_responses: 2 } + account.custom_attributes = { captain_responses_usage: 2 } + account.save! + + post "/api/v1/accounts/#{account.id}/captain/copilot_threads", + params: valid_params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + + expect(CopilotMessage.last.message['content']).to eq( + 'You are out of Copilot credits. You can buy more credits from the billing section.' + ) + end + it 'creates a new copilot thread with initial message' do + account.limits = { captain_responses: 2 } + account.custom_attributes = { captain_responses_usage: 0 } + account.save! + expect do post "/api/v1/accounts/#{account.id}/captain/copilot_threads", params: valid_params, @@ -103,8 +124,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do expect(thread.assistant_id).to eq(assistant.id) message = thread.copilot_messages.last - expect(message.message_type).to eq('user') expect(message.message).to eq({ 'content' => valid_params[:message] }) + + expect(Captain::Copilot::ResponseJob).to have_been_enqueued.with( + assistant: assistant, + conversation_id: valid_params[:conversation_id], + user_id: agent.id, + copilot_thread_id: thread.id, + message: valid_params[:message] + ) end end end From 24ea968b00eacde7de7173aab959e7ae8a97d08b Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 1 Jul 2025 09:43:44 +0530 Subject: [PATCH 10/28] chore: Remove older UI (#11720) --- app/javascript/dashboard/App.vue | 2 +- .../dashboard/assets/scss/_animations.scss | 101 --- .../assets/scss/{widgets => }/_base.scss | 5 + .../dashboard/assets/scss/_formulate.scss | 38 - .../assets/scss/_helper-classes.scss | 21 - .../dashboard/assets/scss/_layout.scss | 48 -- .../dashboard/assets/scss/_mixins.scss | 101 --- .../dashboard/assets/scss/_rtl.scss | 204 ----- .../dashboard/assets/scss/_variables.scss | 97 --- .../dashboard/assets/scss/_woot.scss | 434 ++-------- .../scss/{ => plugins}/_date-picker.scss | 16 +- .../assets/scss/plugins/_dropdown.scss | 7 - .../assets/scss/plugins/_multiselect.scss | 7 +- .../assets/scss/views/settings/inbox.scss | 1 - .../scss/views/settings/integrations.scss | 1 - .../scss/widgets/_conversation-view.scss | 261 ------ .../dashboard/assets/scss/widgets/_tabs.scss | 77 -- .../assets/scss/widgets/_woot-tables.scss | 90 -- .../LiveChatCampaignDialog.vue | 4 +- .../LiveChatCampaign/LiveChatCampaignForm.vue | 2 +- .../SMSCampaign/SMSCampaignDialog.vue | 4 +- .../SMSCampaign/SMSCampaignForm.vue | 2 +- .../Contacts/ContactsSidebar/ContactMerge.vue | 2 +- .../components-next/Editor/Editor.vue | 2 +- .../components-next/EmptyStateLayout.vue | 4 +- .../ArticleCard/ArticleCard.story.vue | 2 +- .../CategoryCard/CategoryCard.story.vue | 2 +- .../HelpCenter/CategoryCard/CategoryCard.vue | 6 +- .../Article/ArticleEmptyState.story.vue | 2 +- .../Portal/PortalEmptyState.story.vue | 2 +- .../LocaleCard/LocaleCard.story.vue | 2 +- .../HelpCenter/LocaleCard/LocaleCard.vue | 14 +- .../Pages/ArticleEditorPage/ArticleEditor.vue | 8 +- .../ArticleEditorControls.vue | 4 +- .../ArticleEditorPage/ArticleEditorHeader.vue | 2 +- .../Pages/ArticlePage/ArticlesPage.story.vue | 2 +- .../CategoryPage/CategoriesPage.story.vue | 2 +- .../Pages/CategoryPage/CategoryDialog.vue | 4 +- .../Pages/CategoryPage/CategoryForm.vue | 2 +- .../CategoryPage/CategoryHeaderControls.vue | 6 +- .../Pages/LocalePage/LocalesPage.story.vue | 2 +- .../Pages/LocalePage/LocalesPage.vue | 2 +- .../PortalSettingsPage/PortalBaseSettings.vue | 14 +- .../PortalSettings.story.vue | 2 +- .../PortalSettingsPage/PortalSettings.vue | 4 +- .../PortalSwitcher/PortalSwitcher.story.vue | 2 +- .../PortalSwitcher/PortalSwitcher.vue | 4 +- .../components-next/avatar/Avatar.story.vue | 14 +- .../breadcrumb/Breadcrumb.story.vue | 10 +- .../components-next/breadcrumb/Breadcrumb.vue | 4 +- .../components-next/button/Button.story.vue | 20 +- .../button/ConfirmButton.story.vue | 4 +- .../captain/assistant/AssistantCard.story.vue | 2 +- .../captain/assistant/DocumentCard.story.vue | 2 +- .../captain/assistant/InboxCard.story.vue | 2 +- .../captain/assistant/ResponseCard.story.vue | 2 +- .../assistant/AssistantForm.vue | 2 +- .../pageComponents/document/DocumentForm.vue | 2 +- .../pageComponents/inbox/ConnectInboxForm.vue | 2 +- .../pageComponents/response/ResponseForm.vue | 2 +- .../colorpicker/ColorPicker.vue | 2 +- .../combobox/ComboBox.story.vue | 4 +- .../combobox/ComboBoxDropdown.vue | 7 +- .../TagMultiSelectComboxBox.story.vue | 8 +- .../copilot/CopilotLauncher.vue | 5 +- .../dropdown-menu/DropdownMenu.story.vue | 8 +- .../DropdownPrimitives.story.vue | 2 +- .../inline-input/InlineInput.story.vue | 14 +- .../components-next/input/Input.story.vue | 14 +- .../dashboard/components-next/input/Input.vue | 2 +- .../components-next/message/bubbles/Dyte.vue | 10 +- .../components-next/message/chips/File.vue | 2 +- .../pagination/PaginationFooter.story.vue | 10 +- .../sidebar/SidebarGroupHeader.vue | 3 +- .../sidebar/SidebarGroupLeaf.vue | 2 +- .../sidebar/SidebarProfileMenu.vue | 6 +- .../components-next/switch/Switch.vue | 6 +- .../components-next/tabbar/TabBar.story.vue | 8 +- .../textarea/TextArea.story.vue | 14 +- .../components-next/textarea/TextArea.vue | 4 +- .../dashboard/components/ChatList.vue | 7 +- .../dashboard/components/ChatListHeader.vue | 15 - .../dashboard/components/FormSection.vue | 6 +- .../dashboard/components/ModalHeader.vue | 6 +- .../dashboard/components/SettingsSection.vue | 9 +- .../dashboard/components/SidemenuIcon.vue | 49 -- .../AddAccountModal.vue | 0 .../components/buttons/ResolveAction.vue | 10 +- .../components/copilot/CopilotContainer.vue | 2 +- app/javascript/dashboard/components/index.js | 2 - .../components/layout/AvailabilityStatus.vue | 157 ---- .../dashboard/components/layout/Sidebar.vue | 246 ------ .../layout/config/default-sidebar.js | 19 - .../layout/config/sidebarItems/campaigns.js | 26 - .../layout/config/sidebarItems/contacts.js | 32 - .../config/sidebarItems/conversations.js | 50 -- .../config/sidebarItems/notifications.js | 7 - .../layout/config/sidebarItems/primaryMenu.js | 70 -- .../config/sidebarItems/profileSettings.js | 7 - .../layout/config/sidebarItems/reports.js | 87 -- .../layout/config/sidebarItems/settings.js | 220 ----- .../sidebarComponents/AccountContext.vue | 110 --- .../sidebarComponents/AccountSelector.vue | 88 -- .../layout/sidebarComponents/AgentDetails.vue | 45 - .../layout/sidebarComponents/Logo.vue | 33 - .../sidebarComponents/NotificationBell.vue | 58 -- .../layout/sidebarComponents/OptionsMenu.vue | 204 ----- .../layout/sidebarComponents/Primary.vue | 116 --- .../sidebarComponents/PrimaryNavItem.vue | 148 ---- .../layout/sidebarComponents/Secondary.vue | 265 ------ .../SecondaryChildNavItem.vue | 132 --- .../sidebarComponents/SecondaryNavItem.vue | 277 ------ .../specs/AccountSelector.spec.js | 73 -- .../specs/AgentDetails.spec.js | 61 -- .../specs/NotificationBell.spec.js | 88 -- .../layout/specs/AvailabilityStatus.spec.js | 71 -- .../components/specs/SidemenuIcon.spec.js | 31 - .../__snapshots__/SidemenuIcon.spec.js.snap | 14 - .../dashboard/components/table/BaseCell.vue | 2 +- .../dashboard/components/ui/Banner.vue | 12 +- .../components/CalendarDateInput.vue | 2 +- .../components/DatePickerButton.vue | 16 +- .../components/ui/HelperTextPopup.vue | 6 +- .../dashboard/components/ui/Label.vue | 42 +- .../dashboard/components/ui/PreviewCard.vue | 23 +- .../dashboard/components/ui/Switch.vue | 21 +- .../dashboard/components/ui/Tabs/Tabs.vue | 23 +- .../dashboard/components/ui/Tabs/TabsItem.vue | 30 +- .../dashboard/components/ui/TimeAgo.vue | 2 +- .../dashboard/components/ui/Wizard.vue | 13 +- .../widgets/AIAssistanceCTAButton.vue | 4 +- .../components/widgets/AIAssistanceModal.vue | 4 +- .../dashboard/components/widgets/AILoader.vue | 14 +- .../components/widgets/AttachmentsPreview.vue | 2 +- .../widgets/AutomationActionInput.vue | 4 +- .../AutomationActionTeamMessageInput.vue | 4 +- .../widgets/AutomationFileInput.vue | 6 +- .../dashboard/components/widgets/Avatar.vue | 2 +- .../components/widgets/ChatTypeTabs.vue | 15 +- .../components/widgets/ColorPicker.vue | 7 +- .../components/widgets/EmptyState.vue | 2 +- .../components/widgets/FilterInput/Index.vue | 8 +- .../components/widgets/LoadingState.vue | 18 +- .../components/widgets/SettingIntroBanner.vue | 7 +- .../dashboard/components/widgets/ShowMore.vue | 2 +- .../components/widgets/TableFooterResults.vue | 2 +- .../components/widgets/Thumbnail.vue | 24 +- .../components/widgets/ThumbnailGroup.vue | 22 +- .../components/widgets/WootWriter/Editor.vue | 18 +- .../widgets/WootWriter/ReplyBottomPanel.vue | 6 +- .../widgets/WootWriter/ReplyTopPanel.vue | 2 +- .../WootWriter/keyboardEmojiSelector.vue | 4 +- .../conversation/AvailabilityStatusBadge.vue | 27 - .../widgets/conversation/ChatFilter.vue | 1 - .../widgets/conversation/ConversationBox.vue | 22 +- .../widgets/conversation/ConversationCard.vue | 6 +- .../EmptyState/EmptyStateMessage.vue | 4 +- .../widgets/conversation/Message.vue | 800 ------------------ .../widgets/conversation/MessagePreview.vue | 10 +- .../MessageSignatureMissingAlert.vue | 2 +- .../widgets/conversation/MessagesView.vue | 150 +--- .../conversation/OnboardingFeatureCard.vue | 8 +- .../widgets/conversation/OnboardingView.vue | 4 +- .../widgets/conversation/ReplyBox.vue | 60 +- .../widgets/conversation/ShopifyOrderItem.vue | 10 +- .../widgets/conversation/TagAgents.vue | 2 +- .../widgets/conversation/VariableList.vue | 2 +- .../widgets/conversation/bubble/Actions.vue | 357 -------- .../widgets/conversation/bubble/Contact.vue | 123 --- .../widgets/conversation/bubble/File.vue | 86 -- .../widgets/conversation/bubble/Image.vue | 34 - .../conversation/bubble/ImageAudioVideo.vue | 127 --- .../conversation/bubble/InstagramStory.vue | 53 -- .../bubble/InstagramStoryErrorPlaceHolder.vue | 14 - .../bubble/InstagramStoryReply.vue | 36 - .../conversation/bubble/Integration.vue | 41 - .../widgets/conversation/bubble/Location.vue | 52 -- .../widgets/conversation/bubble/MailHead.vue | 100 --- .../widgets/conversation/bubble/ReplyTo.vue | 57 -- .../widgets/conversation/bubble/Text.vue | 162 ---- .../widgets/conversation/bubble/Video.vue | 43 - .../conversation/bubble/integrations/Dyte.vue | 98 --- .../contextMenu/agentLoadingPlaceholder.vue | 6 +- .../conversation/contextMenu/menuItem.vue | 9 +- .../contextMenu/menuItemWithSubmenu.vue | 2 +- .../conversation/LabelSuggestion.vue | 21 +- .../conversationBulkActions/AgentSelector.vue | 11 +- .../conversationBulkActions/Index.vue | 11 +- .../conversationBulkActions/LabelActions.vue | 21 +- .../conversationBulkActions/TeamActions.vue | 15 +- .../conversationBulkActions/UpdateActions.vue | 6 +- .../conversationCardComponents/CardLabels.vue | 2 +- .../helpers/botMessageContentHelper.js | 87 -- .../specs/botMessageContentHelper.spec.js | 66 -- .../conversation/linear/CreateOrLinkIssue.vue | 3 +- .../widgets/forms/AvatarUploader.vue | 9 +- .../components/widgets/forms/PhoneInput.vue | 2 +- .../widgets/mentions/MentionBox.vue | 4 +- .../widgets/modal/WootKeyShortcutModal.vue | 8 +- .../dashboard/helper/permissionsHelper.js | 26 - .../dashboard/helper/portalHelper.js | 36 - .../helper/specs/permissionsHelper.spec.js | 65 -- .../components/ContactDropdownItem.vue | 6 +- .../contact/components/MergeContact.vue | 2 +- .../components/MergeContactSummary.vue | 4 +- .../components/MessageContextMenu.vue | 6 +- .../modules/search/components/SearchTabs.vue | 1 + .../widget-preview/components/Widget.vue | 4 +- .../widget-preview/components/WidgetBody.vue | 2 +- .../components/WidgetFooter.vue | 14 +- .../widget-preview/components/WidgetHead.vue | 12 +- .../dashboard/routes/dashboard/Dashboard.vue | 92 +- .../routes/dashboard/commands/commandbar.vue | 2 +- .../conversation/ContactConversations.vue | 2 +- .../conversation/ContactDetailsItem.vue | 2 +- .../dashboard/conversation/ContactPanel.vue | 2 +- .../conversation/ConversationAction.vue | 2 +- .../conversation/ConversationParticipant.vue | 24 +- .../conversation/ConversationView.vue | 18 +- .../conversation/Macros/MacroPreview.vue | 4 +- .../conversation/contact/ContactForm.vue | 6 +- .../conversation/contact/SocialIcons.vue | 2 +- .../conversation/labels/LabelBox.vue | 51 +- .../conversation/search/PopOverSearch.vue | 78 -- .../ArticleSearch/ArticleSearchResultItem.vue | 6 +- .../components/ArticleSearch/Header.vue | 4 +- .../ArticleSearch/SearchPopover.vue | 2 +- .../ArticleSearch/SearchResults.vue | 18 +- .../helpcenter/components/UpgradePage.vue | 12 +- .../pages/HelpCenterPageRouteView.vue | 2 +- .../helpcenter/pages/PortalsIndexPage.vue | 2 +- .../dashboard/inbox/InboxEmptyState.vue | 17 +- .../routes/dashboard/inbox/InboxList.vue | 8 +- .../routes/dashboard/inbox/InboxView.vue | 6 +- .../inbox/components/InboxDisplayMenu.vue | 4 +- .../routes/dashboard/inbox/routes.js | 4 - .../components/NotificationPanel.vue | 230 ----- .../components/NotificationPanelItem.vue | 106 --- .../components/NotificationPanelList.vue | 81 -- .../components/NotificationTable.vue | 23 +- .../components/NotificationsView.vue | 16 +- .../routes/dashboard/notifications/routes.js | 1 - .../dashboard/settings/SettingsHeader.vue | 7 +- .../dashboard/settings/SettingsLayout.vue | 2 +- .../settings/SettingsSubPageHeader.vue | 6 +- .../routes/dashboard/settings/Wrapper.vue | 2 - .../account/components/AccountDelete.vue | 4 +- .../dashboard/settings/agents/Index.vue | 4 +- .../settings/attributes/AddAttribute.vue | 25 +- .../settings/attributes/CustomAttribute.vue | 4 +- .../settings/attributes/EditAttribute.vue | 25 +- .../dashboard/settings/attributes/Index.vue | 3 +- .../dashboard/settings/auditlogs/Index.vue | 6 +- .../settings/automation/AddAutomationRule.vue | 2 +- .../automation/EditAutomationRule.vue | 2 +- .../dashboard/settings/automation/Index.vue | 2 +- .../dashboard/settings/canned/Index.vue | 7 +- .../components/BaseSettingsHeader.vue | 4 +- .../components/BaseSettingsListItem.vue | 6 +- .../dashboard/settings/customRoles/Index.vue | 7 +- .../customRoles/component/CustomRoleModal.vue | 2 +- .../component/CustomRolePaywall.vue | 2 +- .../dashboard/settings/inbox/FinishSetup.vue | 4 +- .../routes/dashboard/settings/inbox/Index.vue | 4 +- .../inbox/PreChatForm/PreChatFields.vue | 6 +- .../settings/inbox/PreChatForm/Settings.vue | 6 +- .../dashboard/settings/inbox/Settings.vue | 11 +- .../settings/inbox/WidgetBuilder.vue | 2 +- .../settings/inbox/channels/Instagram.vue | 2 +- .../settings/inbox/components/BusinessDay.vue | 4 +- .../inbox/components/InputRadioGroup.vue | 8 +- .../components/SenderNameExamplePreview.vue | 2 +- .../inbox/components/WeeklyAvailability.vue | 2 +- .../settings/inbox/facebook/Reauthorize.vue | 4 +- .../inbox/settingsPage/CollaboratorsPage.vue | 15 +- .../integrations/DashboardApps/Index.vue | 8 +- .../integrations/MultipleIntegrationHooks.vue | 12 +- .../settings/integrations/NewHook.vue | 2 +- .../settings/integrations/Shopify.vue | 2 +- .../Slack/SelectChannelWarning.vue | 2 +- .../settings/integrations/Webhooks/Index.vue | 6 +- .../integrations/Webhooks/WebhookRow.vue | 4 +- .../dashboard/settings/labels/Index.vue | 16 +- .../dashboard/settings/macros/Index.vue | 2 +- .../dashboard/settings/macros/MacroNode.vue | 4 +- .../dashboard/settings/macros/MacroNodes.vue | 17 +- .../settings/macros/MacroProperties.vue | 24 +- .../settings/profile/AccessToken.vue | 2 +- .../settings/profile/AudioAlertCondition.vue | 4 +- .../settings/profile/AudioAlertEvent.vue | 4 +- .../settings/profile/AudioAlertTone.vue | 2 +- .../settings/profile/ChangePassword.vue | 20 +- .../dashboard/settings/profile/HotKeyCard.vue | 10 +- .../dashboard/settings/profile/Index.vue | 2 +- .../settings/profile/MessageSignature.vue | 16 +- .../settings/profile/NotificationCheckBox.vue | 2 +- .../profile/NotificationPreferences.vue | 26 +- .../settings/profile/UserBasicDetails.vue | 15 +- .../settings/profile/UserProfilePicture.vue | 2 +- .../dashboard/settings/profile/Wrapper.vue | 2 +- .../settings/reports/ReportContainer.vue | 2 +- .../components/ChartElements/ChartStats.vue | 8 +- .../reports/components/ConversationCell.vue | 5 +- .../settings/reports/components/CsatTable.vue | 2 +- .../components/Filters/DateGroupBy.vue | 2 +- .../reports/components/Filters/Labels.vue | 6 +- .../settings/reports/components/Heatmap.vue | 30 +- .../reports/components/ReportFilters.vue | 10 +- .../reports/components/SLA/SLAFilter.vue | 7 +- .../reports/components/SLA/SLAReportItem.vue | 6 +- .../reports/components/SLA/SLAViewDetails.vue | 2 +- .../reports/components/overview/AgentCell.vue | 4 +- .../routes/dashboard/settings/sla/SlaForm.vue | 2 +- .../sla/components/SLABusinessHoursLabel.vue | 14 +- .../settings/sla/components/SLAListItem.vue | 4 +- .../sla/components/SLAListItemLoading.vue | 12 +- .../sla/components/SLAResponseTime.vue | 6 +- .../settings/teams/AgentSelector.vue | 13 +- .../settings/teams/Create/AddAgents.vue | 2 +- .../settings/teams/Edit/EditAgents.vue | 2 +- .../routes/dashboard/settings/teams/Index.vue | 9 +- .../routes/dashboard/suspended/Index.vue | 2 +- app/javascript/dashboard/routes/index.js | 2 - .../shared/assets/stylesheets/animations.scss | 11 - .../assets/stylesheets/border-radius.scss | 8 - .../shared/assets/stylesheets/colors.scss | 109 --- .../shared/assets/stylesheets/font-size.scss | 14 - .../assets/stylesheets/font-weights.scss | 8 - .../shared/assets/stylesheets/shadows.scss | 19 - .../shared/assets/stylesheets/spacing.scss | 33 - .../shared/assets/stylesheets/z-index.scss | 14 - .../components/ArticleSkeletonLoader.vue | 30 +- app/javascript/shared/components/Button.vue | 4 +- app/javascript/shared/components/Spinner.vue | 6 +- app/javascript/shared/components/TextArea.vue | 13 +- .../shared/components/emoji/EmojiInput.vue | 17 +- .../components/ui/MultiselectDropdown.vue | 13 +- .../ui/MultiselectDropdownItems.vue | 4 +- .../ui/dropdown/DropdownDivider.vue | 2 +- .../components/ui/dropdown/DropdownHeader.vue | 2 +- .../ui/dropdown/DropdownSubMenu.vue | 14 +- .../components/ui/label/LabelDropdown.vue | 8 +- app/javascript/shared/constants/busEvents.js | 1 - app/javascript/shared/helpers/DateHelper.js | 11 +- .../shared/helpers/specs/DateHelper.spec.js | 32 - app/javascript/v3/App.vue | 6 +- .../v3/components/Button/SubmitButton.vue | 65 -- app/javascript/v3/components/Form/Button.vue | 101 --- .../v3/components/Form/CheckBox.vue | 2 +- .../v3/components/Form/InitialsAvatar.vue | 43 - app/javascript/v3/components/Form/Switch.vue | 6 +- .../v3/components/Form/Textarea.vue | 85 -- .../v3/components/SnackBar/Item.vue | 2 +- .../v3/views/auth/password/Edit.vue | 14 +- .../v3/views/auth/reset/password/Index.vue | 14 +- .../auth/signup/components/Signup/Form.vue | 24 +- .../signup/components/Testimonials/Index.vue | 2 +- app/javascript/v3/views/login/Index.vue | 16 +- .../v3/views/onboarding/OnboardingStep.vue | 14 +- app/javascript/widget/assets/scss/woot.scss | 3 - .../widget/components/ChatHeader.vue | 2 +- .../widget/components/FooterReplyTo.vue | 4 +- .../widget/components/MessageReplyButton.vue | 2 +- .../widget/components/ReplyToChip.vue | 6 +- .../widget/components/UnreadMessageList.vue | 4 +- .../widget/components/template/Article.vue | 2 +- .../account_features_field/_form.html.erb | 4 +- .../_form.html.erb | 2 +- theme/colors.js | 182 +--- 369 files changed, 974 insertions(+), 9363 deletions(-) delete mode 100644 app/javascript/dashboard/assets/scss/_animations.scss rename app/javascript/dashboard/assets/scss/{widgets => }/_base.scss (98%) delete mode 100644 app/javascript/dashboard/assets/scss/_formulate.scss delete mode 100644 app/javascript/dashboard/assets/scss/_helper-classes.scss delete mode 100644 app/javascript/dashboard/assets/scss/_layout.scss delete mode 100644 app/javascript/dashboard/assets/scss/_mixins.scss delete mode 100644 app/javascript/dashboard/assets/scss/_rtl.scss delete mode 100644 app/javascript/dashboard/assets/scss/_variables.scss rename app/javascript/dashboard/assets/scss/{ => plugins}/_date-picker.scss (73%) delete mode 100644 app/javascript/dashboard/assets/scss/plugins/_dropdown.scss delete mode 100644 app/javascript/dashboard/assets/scss/views/settings/inbox.scss delete mode 100644 app/javascript/dashboard/assets/scss/views/settings/integrations.scss delete mode 100644 app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss delete mode 100644 app/javascript/dashboard/assets/scss/widgets/_tabs.scss delete mode 100644 app/javascript/dashboard/assets/scss/widgets/_woot-tables.scss delete mode 100644 app/javascript/dashboard/components/SidemenuIcon.vue rename app/javascript/dashboard/components/{layout/sidebarComponents => app}/AddAccountModal.vue (100%) delete mode 100644 app/javascript/dashboard/components/layout/AvailabilityStatus.vue delete mode 100644 app/javascript/dashboard/components/layout/Sidebar.vue delete mode 100644 app/javascript/dashboard/components/layout/config/default-sidebar.js delete mode 100644 app/javascript/dashboard/components/layout/config/sidebarItems/campaigns.js delete mode 100644 app/javascript/dashboard/components/layout/config/sidebarItems/contacts.js delete mode 100644 app/javascript/dashboard/components/layout/config/sidebarItems/conversations.js delete mode 100644 app/javascript/dashboard/components/layout/config/sidebarItems/notifications.js delete mode 100644 app/javascript/dashboard/components/layout/config/sidebarItems/primaryMenu.js delete mode 100644 app/javascript/dashboard/components/layout/config/sidebarItems/profileSettings.js delete mode 100644 app/javascript/dashboard/components/layout/config/sidebarItems/reports.js delete mode 100644 app/javascript/dashboard/components/layout/config/sidebarItems/settings.js delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/AccountContext.vue delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/AccountSelector.vue delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/AgentDetails.vue delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/Logo.vue delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/NotificationBell.vue delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/OptionsMenu.vue delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/Primary.vue delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/PrimaryNavItem.vue delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/SecondaryChildNavItem.vue delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/SecondaryNavItem.vue delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/specs/AccountSelector.spec.js delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/specs/AgentDetails.spec.js delete mode 100644 app/javascript/dashboard/components/layout/sidebarComponents/specs/NotificationBell.spec.js delete mode 100644 app/javascript/dashboard/components/layout/specs/AvailabilityStatus.spec.js delete mode 100644 app/javascript/dashboard/components/specs/SidemenuIcon.spec.js delete mode 100644 app/javascript/dashboard/components/specs/__snapshots__/SidemenuIcon.spec.js.snap delete mode 100644 app/javascript/dashboard/components/widgets/conversation/AvailabilityStatusBadge.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/ChatFilter.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/Message.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/Contact.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/File.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/Image.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/ImageAudioVideo.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/InstagramStory.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/InstagramStoryErrorPlaceHolder.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/InstagramStoryReply.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/Integration.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/Location.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/MailHead.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/ReplyTo.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/Video.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/bubble/integrations/Dyte.vue delete mode 100644 app/javascript/dashboard/components/widgets/conversation/helpers/botMessageContentHelper.js delete mode 100644 app/javascript/dashboard/components/widgets/conversation/helpers/specs/botMessageContentHelper.spec.js delete mode 100644 app/javascript/dashboard/routes/dashboard/conversation/search/PopOverSearch.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/notifications/components/NotificationPanel.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/notifications/components/NotificationPanelItem.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/notifications/components/NotificationPanelList.vue delete mode 100644 app/javascript/shared/assets/stylesheets/animations.scss delete mode 100644 app/javascript/shared/assets/stylesheets/border-radius.scss delete mode 100644 app/javascript/shared/assets/stylesheets/colors.scss delete mode 100644 app/javascript/shared/assets/stylesheets/font-size.scss delete mode 100644 app/javascript/shared/assets/stylesheets/font-weights.scss delete mode 100644 app/javascript/shared/assets/stylesheets/shadows.scss delete mode 100644 app/javascript/shared/assets/stylesheets/spacing.scss delete mode 100644 app/javascript/shared/assets/stylesheets/z-index.scss delete mode 100644 app/javascript/v3/components/Button/SubmitButton.vue delete mode 100644 app/javascript/v3/components/Form/Button.vue delete mode 100644 app/javascript/v3/components/Form/InitialsAvatar.vue delete mode 100644 app/javascript/v3/components/Form/Textarea.vue diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index e51958e9e..675f6ea67 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -1,6 +1,6 @@