diff --git a/.circleci/config.yml b/.circleci/config.yml index 804c63857..09bd5191d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,7 +76,7 @@ jobs: bundle install - node/install: - node-version: '23.7' + node-version: '24.13' - node/install-pnpm - node/install-packages: pkg-manager: pnpm @@ -117,7 +117,7 @@ jobs: steps: - checkout - node/install: - node-version: '23.7' + node-version: '24.13' - node/install-pnpm - node/install-packages: pkg-manager: pnpm @@ -148,7 +148,7 @@ jobs: steps: - checkout - node/install: - node-version: '23.7' + node-version: '24.13' - node/install-pnpm - node/install-packages: pkg-manager: pnpm @@ -218,6 +218,49 @@ jobs: source ~/.rvm/scripts/rvm bundle install + # Install and configure OpenSearch + - run: + name: Install OpenSearch + command: | + # Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients) + wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz + tar -xzf opensearch-2.11.0-linux-x64.tar.gz + sudo mv opensearch-2.11.0 /opt/opensearch + + - run: + name: Configure and Start OpenSearch + command: | + # Configure OpenSearch for single-node testing + cat > /opt/opensearch/config/opensearch.yml \<< EOF + cluster.name: chatwoot-test + node.name: node-1 + network.host: 0.0.0.0 + http.port: 9200 + discovery.type: single-node + plugins.security.disabled: true + EOF + + # Set ownership and permissions + sudo chown -R $USER:$USER /opt/opensearch + + # Start OpenSearch in background + /opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid + + - run: + name: Wait for OpenSearch to be ready + command: | + echo "Waiting for OpenSearch to start..." + for i in {1..30}; do + if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then + echo "OpenSearch is ready!" + exit 0 + fi + echo "Waiting... ($i/30)" + sleep 2 + done + echo "OpenSearch failed to start" + exit 1 + # Configure environment and database - run: name: Database Setup and Configure Environment Variables @@ -234,6 +277,7 @@ jobs: sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env echo -en "\nINSTALLATION_ENV=circleci" >> ".env" + echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env" # Database setup - run: diff --git a/.devcontainer/docker-compose.base.yml b/.devcontainer/docker-compose.base.yml index 6932b5f10..375742ff7 100644 --- a/.devcontainer/docker-compose.base.yml +++ b/.devcontainer/docker-compose.base.yml @@ -10,7 +10,7 @@ services: dockerfile: .devcontainer/Dockerfile.base args: VARIANT: 'ubuntu-22.04' - NODE_VERSION: '23.7.0' + NODE_VERSION: '24.13.0' RUBY_VERSION: '3.4.4' # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. USER_UID: '1000' diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index a9185ea09..d696f99cc 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -11,7 +11,7 @@ services: dockerfile: .devcontainer/Dockerfile args: VARIANT: 'ubuntu-22.04' - NODE_VERSION: '23.7.0' + NODE_VERSION: '24.13.0' RUBY_VERSION: '3.4.4' # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. USER_UID: '1000' diff --git a/.env.example b/.env.example index 844aafb6a..6963ba308 100644 --- a/.env.example +++ b/.env.example @@ -275,6 +275,8 @@ AZURE_APP_SECRET= # contact_inboxes with no conversation older than 90 days will be removed # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false +# REDIS_ALFRED_SIZE=10 + # Baileys API Whatsapp provider BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025 diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 38d70a737..a7b19cd7a 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 9061bc6fe..1487d96a5 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -26,7 +26,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies run: pnpm i @@ -41,7 +41,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies run: pnpm i @@ -92,7 +92,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index c2a4bd174..7869bf89c 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: pnpm diff --git a/.nvmrc b/.nvmrc index b88575e38..cf2efde81 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -23.7.0 \ No newline at end of file +24.13.0 \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 35fade7e6..866cacef1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -441,7 +441,8 @@ GEM http-cookie (1.0.5) domain_name (~> 0.5) http-form_data (2.3.0) - httparty (0.21.0) + httparty (0.24.0) + csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) @@ -555,7 +556,8 @@ GEM ruby2_keywords msgpack (1.8.0) multi_json (1.15.0) - multi_xml (0.6.0) + multi_xml (0.8.0) + bigdecimal (>= 3.1, < 5) multipart-post (2.3.0) mutex_m (0.3.0) neighbor (0.2.3) diff --git a/README.md b/README.md index 21316b422..d8b8ae7a2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ ___ The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
-
{{ section.title }}
More Content Click me
- {{ category }}
-
- {{ truncatedContent }}
-
-
- {{ email }}
-
- •
- {{ phone }}
-
{{ $t('SEARCH.EMPTY_STATE', { item: titleCase, query }) }}
@@ -137,4 +136,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
-*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License.
+*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License.
diff --git a/VERSION_CW b/VERSION_CW
index 88f181192..2da431623 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-4.8.0
+4.10.0
diff --git a/VERSION_CWCTL b/VERSION_CWCTL
index 6cb9d3dd0..1545d9665 100644
--- a/VERSION_CWCTL
+++ b/VERSION_CWCTL
@@ -1 +1 @@
-3.4.3
+3.5.0
diff --git a/app/builders/v2/reports/channel_summary_builder.rb b/app/builders/v2/reports/channel_summary_builder.rb
new file mode 100644
index 000000000..2df8fc081
--- /dev/null
+++ b/app/builders/v2/reports/channel_summary_builder.rb
@@ -0,0 +1,38 @@
+class V2::Reports::ChannelSummaryBuilder
+ include DateRangeHelper
+
+ pattr_initialize [:account!, :params!]
+
+ def build
+ conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
+ end
+
+ private
+
+ def conversations_by_channel_and_status
+ account.conversations
+ .joins(:inbox)
+ .where(created_at: range)
+ .group('inboxes.channel_type', 'conversations.status')
+ .count
+ .each_with_object({}) do |((channel_type, status), count), grouped|
+ grouped[channel_type] ||= {}
+ grouped[channel_type][status] = count
+ end
+ end
+
+ def build_channel_stats(status_counts)
+ open_count = status_counts['open'] || 0
+ resolved_count = status_counts['resolved'] || 0
+ pending_count = status_counts['pending'] || 0
+ snoozed_count = status_counts['snoozed'] || 0
+
+ {
+ open: open_count,
+ resolved: resolved_count,
+ pending: pending_count,
+ snoozed: snoozed_count,
+ total: open_count + resolved_count + pending_count + snoozed_count
+ }
+ end
+end
diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
new file mode 100644
index 000000000..156c031fa
--- /dev/null
+++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb
@@ -0,0 +1,76 @@
+class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
+ before_action :current_account
+ before_action :authorize_account_update, only: [:update]
+
+ def show
+ render json: preferences_payload
+ end
+
+ def update
+ params_to_update = captain_params
+ @current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
+ @current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
+ @current_account.save!
+
+ render json: preferences_payload
+ end
+
+ private
+
+ def preferences_payload
+ {
+ providers: Llm::Models.providers,
+ models: Llm::Models.models,
+ features: features_with_account_preferences
+ }
+ end
+
+ def authorize_account_update
+ authorize @current_account, :update?
+ end
+
+ def captain_params
+ permitted = {}
+ permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
+ permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
+ permitted
+ end
+
+ def merged_captain_models
+ existing_models = @current_account.captain_models || {}
+ existing_models.merge(permitted_captain_models)
+ end
+
+ def merged_captain_features
+ existing_features = @current_account.captain_features || {}
+ existing_features.merge(permitted_captain_features)
+ end
+
+ def permitted_captain_models
+ params.require(:captain_models).permit(
+ :editor, :assistant, :copilot, :label_suggestion,
+ :audio_transcription, :help_center_search
+ ).to_h.stringify_keys
+ end
+
+ def permitted_captain_features
+ params.require(:captain_features).permit(
+ :editor, :assistant, :copilot, :label_suggestion,
+ :audio_transcription, :help_center_search
+ ).to_h.stringify_keys
+ end
+
+ def features_with_account_preferences
+ preferences = Current.account.captain_preferences
+ account_features = preferences[:features] || {}
+ account_models = preferences[:models] || {}
+
+ Llm::Models.feature_keys.index_with do |feature_key|
+ config = Llm::Models.feature_config(feature_key)
+ config.merge(
+ enabled: account_features[feature_key] == true,
+ selected: account_models[feature_key] || config[:default]
+ )
+ end
+ end
+end
diff --git a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb
index f5bed6c34..0cde5f5c1 100644
--- a/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb
+++ b/app/controllers/api/v1/accounts/csat_survey_responses_controller.rb
@@ -50,3 +50,5 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@current_page = params[:page] || 1
end
end
+
+Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
index d17fe35fb..bb5dab680 100644
--- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
+++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
@@ -1,38 +1,27 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
- DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
- DEFAULT_LANGUAGE = 'en'.freeze
-
before_action :fetch_inbox
before_action :validate_whatsapp_channel
def show
- template = @inbox.csat_config&.dig('template')
- return render json: { template_exists: false } unless template
+ service = CsatTemplateManagementService.new(@inbox)
+ result = service.template_status
- template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
- status_result = @inbox.channel.provider_service.get_template_status(template_name)
-
- render_template_status_response(status_result, template_name)
- rescue StandardError => e
- Rails.logger.error "Error fetching CSAT template status: #{e.message}"
- render json: { error: e.message }, status: :internal_server_error
+ if result[:service_error]
+ render json: { error: result[:service_error] }, status: :internal_server_error
+ else
+ render json: result
+ end
end
def create
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
- # Delete existing template even though we are using a new one.
- # We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
- delete_existing_template_if_needed
-
- result = create_template_via_provider(template_params)
+ service = CsatTemplateManagementService.new(@inbox)
+ result = service.create_template(template_params)
render_template_creation_result(result)
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
- rescue StandardError => e
- Rails.logger.error "Error creating CSAT template: #{e.message}"
- render json: { error: 'Template creation failed' }, status: :internal_server_error
end
private
@@ -43,9 +32,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
end
def validate_whatsapp_channel
- return if @inbox.whatsapp?
+ return if @inbox.whatsapp? || @inbox.twilio_whatsapp?
- render json: { error: 'CSAT template operations only available for WhatsApp channels' },
+ render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' },
status: :bad_request
end
@@ -57,35 +46,36 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
- def create_template_via_provider(template_params)
- template_config = {
- message: template_params[:message],
- button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
- base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
- language: template_params[:language] || DEFAULT_LANGUAGE,
- template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
- }
-
- @inbox.channel.provider_service.create_csat_template(template_config)
- end
-
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
+ elsif result[:service_error]
+ render json: { error: result[:service_error] }, status: :internal_server_error
else
render_failed_template_creation(result)
end
end
def render_successful_template_creation(result)
- render json: {
- template: {
- name: result[:template_name],
- template_id: result[:template_id],
- status: 'PENDING',
- language: result[:language] || DEFAULT_LANGUAGE
- }
- }, status: :created
+ if @inbox.twilio_whatsapp?
+ render json: {
+ template: {
+ friendly_name: result[:friendly_name],
+ content_sid: result[:content_sid],
+ status: result[:status] || 'pending',
+ language: result[:language] || 'en'
+ }
+ }, status: :created
+ else
+ render json: {
+ template: {
+ name: result[:template_name],
+ template_id: result[:template_id],
+ status: 'PENDING',
+ language: result[:language] || 'en'
+ }
+ }, status: :created
+ end
end
def render_failed_template_creation(result)
@@ -98,45 +88,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
}, status: :unprocessable_entity
end
- def delete_existing_template_if_needed
- template = @inbox.csat_config&.dig('template')
- return true if template.blank?
-
- template_name = template['name']
- return true if template_name.blank?
-
- template_status = @inbox.channel.provider_service.get_template_status(template_name)
- return true unless template_status[:success]
-
- deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
- if deletion_result[:success]
- Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
- true
- else
- Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
- false
- end
- rescue StandardError => e
- Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
- false
- end
-
- def render_template_status_response(status_result, template_name)
- if status_result[:success]
- render json: {
- template_exists: true,
- template_name: template_name,
- status: status_result[:template][:status],
- template_id: status_result[:template][:id]
- }
- else
- render json: {
- template_exists: false,
- error: 'Template not found'
- }
- end
- end
-
def parse_whatsapp_error(response_body)
return { user_message: nil, technical_details: nil } if response_body.blank?
diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index 4ca58eeed..8e4ea0146 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -214,7 +214,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
- template: [:name, :template_id, :created_at, :language] }] }]
+ template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
end
def permitted_params(channel_attributes = [])
diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb
index 13e3a6a6c..7ee25e02e 100644
--- a/app/controllers/api/v1/accounts/search_controller.rb
+++ b/app/controllers/api/v1/accounts/search_controller.rb
@@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
search_type: search_type,
params: params
).perform
+ rescue ArgumentError => e
+ render json: { error: e.message }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb
index 6e2d0ff4c..714aeb0c9 100644
--- a/app/controllers/api/v2/accounts/reports_controller.rb
+++ b/app/controllers/api/v2/accounts/reports_controller.rb
@@ -38,6 +38,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
end
+ def conversations_summary
+ @report_data = generate_conversations_report
+ generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
+ end
+
def conversation_traffic
@report_data = generate_conversations_heatmap_report
timezone_offset = (params[:timezone_offset] || 0).to_f
diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb
index f31a53c7e..98b3f05d7 100644
--- a/app/controllers/api/v2/accounts/summary_reports_controller.rb
+++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
- before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
+ before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::LabelSummaryBuilder)
end
+ def channel
+ return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
+
+ render_report_with(V2::Reports::ChannelSummaryBuilder)
+ end
+
private
def check_authorization
@@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def permitted_params
params.permit(:since, :until, :business_hours)
end
+
+ def date_range_too_long?
+ return false if permitted_params[:since].blank? || permitted_params[:until].blank?
+
+ since_time = Time.zone.at(permitted_params[:since].to_i)
+ until_time = Time.zone.at(permitted_params[:until].to_i)
+ (until_time - since_time) > 6.months
+ end
end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index abf42517c..d57ad0e53 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -16,7 +16,7 @@ class DashboardController < ActionController::Base
CHATWOOT_INBOX_TOKEN
API_CHANNEL_NAME
API_CHANNEL_THUMBNAIL
- ANALYTICS_TOKEN
+ CLOUD_ANALYTICS_TOKEN
DIRECT_UPLOADS_ENABLED
MAXIMUM_FILE_UPLOAD_SIZE
HCAPTCHA_SITE_KEY
diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb
index 23694d08d..1f34d7e97 100644
--- a/app/helpers/api/v2/accounts/reports_helper.rb
+++ b/app/helpers/api/v2/accounts/reports_helper.rb
@@ -46,6 +46,13 @@ module Api::V2::Accounts::ReportsHelper
end
end
+ def generate_conversations_report
+ builder = V2::Reports::Conversations::MetricBuilder.new(Current.account, build_params(type: :account))
+ summary = builder.summary
+
+ [generate_conversation_report_metrics(summary)]
+ end
+
private
def build_params(base_params)
@@ -71,4 +78,16 @@ module Api::V2::Accounts::ReportsHelper
report[:resolved_conversations_count]
]
end
+
+ def generate_conversation_report_metrics(summary)
+ [
+ summary[:conversations_count],
+ summary[:incoming_messages_count],
+ summary[:outgoing_messages_count],
+ Reports::TimeFormatPresenter.new(summary[:avg_first_response_time]).format,
+ Reports::TimeFormatPresenter.new(summary[:avg_resolution_time]).format,
+ summary[:resolutions_count],
+ Reports::TimeFormatPresenter.new(summary[:reply_time]).format
+ ]
+ end
end
diff --git a/app/javascript/dashboard/api/captain/preferences.js b/app/javascript/dashboard/api/captain/preferences.js
new file mode 100644
index 000000000..f1ce30582
--- /dev/null
+++ b/app/javascript/dashboard/api/captain/preferences.js
@@ -0,0 +1,18 @@
+/* global axios */
+import ApiClient from '../ApiClient';
+
+class CaptainPreferences extends ApiClient {
+ constructor() {
+ super('captain/preferences', { accountScoped: true });
+ }
+
+ get() {
+ return axios.get(this.url);
+ }
+
+ updatePreferences(data) {
+ return axios.put(this.url, data);
+ }
+}
+
+export default new CaptainPreferences();
diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js
index 5323ba60e..f107ddf2d 100644
--- a/app/javascript/dashboard/api/inboxes.js
+++ b/app/javascript/dashboard/api/inboxes.js
@@ -33,6 +33,16 @@ class Inboxes extends CacheEnabledApiClient {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
+ createCSATTemplate(inboxId, template) {
+ return axios.post(`${this.url}/${inboxId}/csat_template`, {
+ template,
+ });
+ }
+
+ getCSATTemplateStatus(inboxId) {
+ return axios.get(`${this.url}/${inboxId}/csat_template`);
+ }
+
setupChannelProvider(inboxId) {
return axios.post(`${this.url}/${inboxId}/setup_channel_provider`);
}
diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js
index c87dfc82b..00f040f8e 100644
--- a/app/javascript/dashboard/api/reports.js
+++ b/app/javascript/dashboard/api/reports.js
@@ -61,6 +61,12 @@ class ReportsAPI extends ApiClient {
});
}
+ getConversationsSummaryReports({ from: since, to: until, businessHours }) {
+ return axios.get(`${this.url}/conversations_summary`, {
+ params: { since, until, business_hours: businessHours },
+ });
+ }
+
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
diff --git a/app/javascript/dashboard/api/search.js b/app/javascript/dashboard/api/search.js
index d533c2f28..10214f3f5 100644
--- a/app/javascript/dashboard/api/search.js
+++ b/app/javascript/dashboard/api/search.js
@@ -14,38 +14,48 @@ class SearchAPI extends ApiClient {
});
}
- contacts({ q, page = 1 }) {
+ contacts({ q, page = 1, since, until }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
page: page,
+ since,
+ until,
},
});
}
- conversations({ q, page = 1 }) {
+ conversations({ q, page = 1, since, until }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
page: page,
+ since,
+ until,
},
});
}
- messages({ q, page = 1 }) {
+ messages({ q, page = 1, since, until, from, inboxId }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
page: page,
+ since,
+ until,
+ from,
+ inbox_id: inboxId,
},
});
}
- articles({ q, page = 1 }) {
+ articles({ q, page = 1, since, until }) {
return axios.get(`${this.url}/articles`, {
params: {
q,
page: page,
+ since,
+ until,
},
});
}
diff --git a/app/javascript/dashboard/api/specs/search.spec.js b/app/javascript/dashboard/api/specs/search.spec.js
new file mode 100644
index 000000000..251ea760e
--- /dev/null
+++ b/app/javascript/dashboard/api/specs/search.spec.js
@@ -0,0 +1,134 @@
+import searchAPI from '../search';
+import ApiClient from '../ApiClient';
+
+describe('#SearchAPI', () => {
+ it('creates correct instance', () => {
+ expect(searchAPI).toBeInstanceOf(ApiClient);
+ expect(searchAPI).toHaveProperty('get');
+ expect(searchAPI).toHaveProperty('contacts');
+ expect(searchAPI).toHaveProperty('conversations');
+ expect(searchAPI).toHaveProperty('messages');
+ expect(searchAPI).toHaveProperty('articles');
+ });
+
+ describe('API calls', () => {
+ const originalAxios = window.axios;
+ const axiosMock = {
+ get: vi.fn(() => Promise.resolve()),
+ };
+
+ beforeEach(() => {
+ window.axios = axiosMock;
+ });
+
+ afterEach(() => {
+ window.axios = originalAxios;
+ vi.clearAllMocks();
+ });
+
+ it('#get', () => {
+ searchAPI.get({ q: 'test query' });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', {
+ params: { q: 'test query' },
+ });
+ });
+
+ it('#contacts', () => {
+ searchAPI.contacts({ q: 'test', page: 1 });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
+ params: { q: 'test', page: 1, since: undefined, until: undefined },
+ });
+ });
+
+ it('#contacts with date filters', () => {
+ searchAPI.contacts({
+ q: 'test',
+ page: 2,
+ since: 1700000000,
+ until: 1732000000,
+ });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
+ params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
+ });
+ });
+
+ it('#conversations', () => {
+ searchAPI.conversations({ q: 'test', page: 1 });
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/search/conversations',
+ {
+ params: { q: 'test', page: 1, since: undefined, until: undefined },
+ }
+ );
+ });
+
+ it('#conversations with date filters', () => {
+ searchAPI.conversations({
+ q: 'test',
+ page: 1,
+ since: 1700000000,
+ until: 1732000000,
+ });
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/search/conversations',
+ {
+ params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 },
+ }
+ );
+ });
+
+ it('#messages', () => {
+ searchAPI.messages({ q: 'test', page: 1 });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
+ params: {
+ q: 'test',
+ page: 1,
+ since: undefined,
+ until: undefined,
+ from: undefined,
+ inbox_id: undefined,
+ },
+ });
+ });
+
+ it('#messages with all filters', () => {
+ searchAPI.messages({
+ q: 'test',
+ page: 1,
+ since: 1700000000,
+ until: 1732000000,
+ from: 'contact:42',
+ inboxId: 10,
+ });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
+ params: {
+ q: 'test',
+ page: 1,
+ since: 1700000000,
+ until: 1732000000,
+ from: 'contact:42',
+ inbox_id: 10,
+ },
+ });
+ });
+
+ it('#articles', () => {
+ searchAPI.articles({ q: 'test', page: 1 });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
+ params: { q: 'test', page: 1, since: undefined, until: undefined },
+ });
+ });
+
+ it('#articles with date filters', () => {
+ searchAPI.articles({
+ q: 'test',
+ page: 2,
+ since: 1700000000,
+ until: 1732000000,
+ });
+ expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
+ params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
+ });
+ });
+ });
+});
diff --git a/app/javascript/dashboard/components-next/CardLayout.vue b/app/javascript/dashboard/components-next/CardLayout.vue
index 462402167..166f5ea4c 100644
--- a/app/javascript/dashboard/components-next/CardLayout.vue
+++ b/app/javascript/dashboard/components-next/CardLayout.vue
@@ -19,7 +19,7 @@ const handleClick = () => {
+ {{ $t('SEARCH.RECENT_SEARCHES') }}
+
+
- {{ title }}
-
-
+
+ {{ title }}
+
+
+
+ {{ category }}
+
+
+ {{ status }}
+
+
+
diff --git a/app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue b/app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue
index 30c6f0557..a8ce1220b 100644
--- a/app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue
+++ b/app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue
@@ -1,8 +1,12 @@
-
- {{ name }}
-
-
+ {{ name }}
+
+
+ {{ $t('SEARCH.UPDATED_AT', { time: updatedAtTime }) }}
+
+
+
diff --git a/app/javascript/dashboard/modules/search/components/SearchResultConversationItem.vue b/app/javascript/dashboard/modules/search/components/SearchResultConversationItem.vue
index 26a11eba3..e0047a65e 100644
--- a/app/javascript/dashboard/modules/search/components/SearchResultConversationItem.vue
+++ b/app/javascript/dashboard/modules/search/components/SearchResultConversationItem.vue
@@ -1,10 +1,12 @@
-
+
-
- {{ $t(item.label) }}:
-
- {{ item.value }}
-
+
+
+ {{ $t(item.label) + ':' }}
+
+ {{ item.value }}
+
+
+
+
diff --git a/app/javascript/dashboard/modules/search/components/SearchResultMessageItem.vue b/app/javascript/dashboard/modules/search/components/SearchResultMessageItem.vue
new file mode 100644
index 000000000..b9272c928
--- /dev/null
+++ b/app/javascript/dashboard/modules/search/components/SearchResultMessageItem.vue
@@ -0,0 +1,169 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/modules/search/components/SearchResultSection.vue b/app/javascript/dashboard/modules/search/components/SearchResultSection.vue
index 13ba1ce87..40d1ca6c6 100644
--- a/app/javascript/dashboard/modules/search/components/SearchResultSection.vue
+++ b/app/javascript/dashboard/modules/search/components/SearchResultSection.vue
@@ -1,6 +1,8 @@
- {{ title }}
+ {{ title }}
- {{ cannedItem.content }}
+ {{ getPlainText(cannedItem.content) }}