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 d5c7a76f9..55750b2f2 100644 --- a/.env.example +++ b/.env.example @@ -274,3 +274,5 @@ AZURE_APP_SECRET= # Set to true if you want to remove stale contact inboxes # contact_inboxes with no conversation older than 90 days will be removed # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false + +# REDIS_ALFRED_SIZE=10 diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 45ff25203..1d1116d0c 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 011f862b0..c2a626388 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -28,7 +28,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies run: pnpm i @@ -43,7 +43,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies run: pnpm i @@ -94,7 +94,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: Install pnpm dependencies diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index c2a4bd174..7869bf89c 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: 24 cache: 'pnpm' - name: pnpm diff --git a/.nvmrc b/.nvmrc index b88575e38..cf2efde81 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -23.7.0 \ No newline at end of file +24.13.0 \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 0a669b606..1cdfabee0 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.

- Maintainability CircleCI Badge Docker Pull Badge Docker Build Badge @@ -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 dad10c76d..2da431623 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.9.2 +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 1c8845c04..322c7c7fe 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, { csat_config: [:display_type, :message, :button_text, :language, { survey_rules: [:operator, { values: [] }], - template: [:name, :template_id, :created_at, :language] }] }] + template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }] end def permitted_params(channel_attributes = []) diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 13e3a6a6c..7ee25e02e 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController search_type: search_type, params: params ).perform + rescue ArgumentError => e + render json: { error: e.message }, status: :unprocessable_entity end end diff --git a/app/controllers/api/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 361b9472f..83ba3e9ba 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient { syncTemplates(inboxId) { 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`); + } } export default new Inboxes(); 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 = () => {