diff --git a/AGENTS.md b/AGENTS.md index 2430fae2b..82acfcc25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,8 +85,8 @@ ## Project-Specific - **Translations**: - - Only update `en.yml` and `en.json` - - Other languages are handled by the community + - For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community + - Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files - Backend i18n → `en.yml`, Frontend i18n → `en.json` - **Frontend**: - Use `components-next/` for message bubbles (the rest is being deprecated) diff --git a/Gemfile.lock b/Gemfile.lock index 68674155e..46a1c0449 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -211,13 +211,14 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - datadog (2.19.0) - datadog-ruby_core_source (~> 3.4, >= 3.4.1) - libdatadog (~> 18.1.0.1.0) - libddwaf (~> 1.24.1.0.3) + datadog (2.38.0) + cgi + datadog-ruby_core_source (~> 3.5, >= 3.5.3) + libdatadog (~> 36.0.0.1.0) + libddwaf (~> 1.30.0.0.0) logger msgpack - datadog-ruby_core_source (3.4.1) + datadog-ruby_core_source (3.5.3) date (3.5.1) debug (1.8.0) irb (>= 1.5.0) @@ -522,15 +523,16 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - libdatadog (18.1.0.1.0) - libdatadog (18.1.0.1.0-x86_64-linux) - libddwaf (1.24.1.0.3) + libdatadog (36.0.0.1.0) + libdatadog (36.0.0.1.0-arm64-darwin) + libdatadog (36.0.0.1.0-x86_64-linux) + libddwaf (1.30.0.0.2) ffi (~> 1.0) - libddwaf (1.24.1.0.3-arm64-darwin) + libddwaf (1.30.0.0.2-arm64-darwin) ffi (~> 1.0) - libddwaf (1.24.1.0.3-x86_64-darwin) + libddwaf (1.30.0.0.2-x86_64-darwin) ffi (~> 1.0) - libddwaf (1.24.1.0.3-x86_64-linux) + libddwaf (1.30.0.0.2-x86_64-linux) ffi (~> 1.0) line-bot-api (1.28.0) lint_roller (1.1.0) @@ -547,7 +549,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.23.1) + loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -740,8 +742,8 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.1) - loofah (~> 2.21) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (7.0.10) i18n (>= 0.7, < 2) diff --git a/LICENSE b/LICENSE index 8f80f35be..d0425a707 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2017-2024 Chatwoot Inc. +Copyright (c) 2017-2026 Chatwoot Inc. Portions of this software are licensed as follows: diff --git a/VERSION_CW b/VERSION_CW index fb0557132..ecbc3b030 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.15.1 +4.16.0 diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb index d2715011c..af68eefc5 100644 --- a/app/builders/agent_builder.rb +++ b/app/builders/agent_builder.rb @@ -2,6 +2,14 @@ # It initializes with necessary attributes and provides a perform method # to create a user and account user in a transaction. class AgentBuilder + LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze + + class LimitExceededError < StandardError + def initialize + super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE) + end + end + # Initializes an AgentBuilder with necessary attributes. # @param email [String] the email of the user. # @param name [String] the name of the user. @@ -14,15 +22,23 @@ class AgentBuilder # Creates a user and account user in a transaction. # @return [User] the created user. def perform - ActiveRecord::Base.transaction do - @user = find_or_create_user - create_account_user + account.with_lock do + raise LimitExceededError unless can_add_agent? + + ActiveRecord::Base.transaction do + @user = find_or_create_user + create_account_user + end end @user end private + def can_add_agent? + account.usage_limits[:agents] > account.account_users.count + end + # Finds a user by email or creates a new one with a temporary password. # @return [User] the found or created user. def find_or_create_user diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index de3d10081..3f0309024 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController - before_action :current_account before_action :check_authorization before_action :agent_bot, except: [:index, :create] diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 438944f04..864c50bb4 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -1,8 +1,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController before_action :fetch_agent, except: [:create, :index, :bulk_create] before_action :check_authorization - before_action :validate_limit, only: [:create] - before_action :validate_limit_for_bulk_create, only: [:bulk_create] def index @agents = agents @@ -20,6 +18,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController ) @agent = builder.perform + rescue AgentBuilder::LimitExceededError => e + render_payment_required(e.message) end def update @@ -36,25 +36,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController def bulk_create emails = params[:emails] - emails.each do |email| - builder = AgentBuilder.new( - email: email, - name: email.split('@').first, - inviter: current_user, - account: Current.account - ) - begin - builder.perform - rescue ActiveRecord::RecordInvalid => e - Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}" - end - end - + bulk_create_agents(emails) # This endpoint is used to bulk create agents during onboarding # onboarding_step key in present in Current account custom attributes, since this is a one time operation - Current.account.custom_attributes.delete('onboarding_step') - Current.account.save! + clear_onboarding_step head :ok + rescue AgentBuilder::LimitExceededError => e + render_payment_required(e.message) end private @@ -87,22 +75,33 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController @agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] }) end - def validate_limit_for_bulk_create - limit_available = params[:emails].count <= available_agent_count + def bulk_create_agents(emails) + Current.account.with_lock do + raise AgentBuilder::LimitExceededError if emails.count > available_agent_count - render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available + emails.each { |email| create_agent_from_email(email) } + end end - def validate_limit - render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent? + def create_agent_from_email(email) + builder = AgentBuilder.new( + email: email, + name: email.split('@').first, + inviter: current_user, + account: Current.account + ) + builder.perform + rescue ActiveRecord::RecordInvalid => e + Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}" + end + + def clear_onboarding_step + Current.account.custom_attributes.delete('onboarding_step') + Current.account.save! end def available_agent_count - Current.account.usage_limits[:agents] - agents.count - end - - def can_add_agent? - available_agent_count.positive? + Current.account.usage_limits[:agents] - Current.account.account_users.count end def delete_user_record(agent) diff --git a/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb b/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb new file mode 100644 index 000000000..54f976b79 --- /dev/null +++ b/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb @@ -0,0 +1,28 @@ +class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController + before_action :check_admin_authorization? + + def show + set_branded_email_layout + end + + def update + unless Current.account.feature_enabled?(:branded_email_templates) + render_could_not_create_error('Branded email templates feature is not enabled') + return + end + + branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout] + EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout) + set_branded_email_layout + rescue ActiveRecord::RecordInvalid => e + render_could_not_create_error(e.record.errors.full_messages.join(', ')) + end + + private + + def set_branded_email_layout + @branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body + end +end + +Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController') diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb index 482b001d6..3b7fafad5 100644 --- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController - before_action :current_account before_action :authorize_account_update, only: [:update] def show diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index bb9ca2a70..822fce7db 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -45,11 +45,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def update - inbox_params = permitted_params.except(:channel, :csat_config) - inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present? - @inbox.update!(inbox_params) - update_inbox_working_hours - update_channel if channel_update_required? + continue_update = false + + ActiveRecord::Base.transaction do + continue_update = update_branded_email_layout + raise ActiveRecord::Rollback unless continue_update + + inbox_params = permitted_params.except(:channel, :csat_config) + inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present? + @inbox.update!(inbox_params) + update_inbox_working_hours + update_channel if channel_update_required? + end + + return unless continue_update end def agent_bot @@ -155,6 +164,34 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController formatted['template'] = config['template'] if config['template'].present? end + def update_branded_email_layout + return true unless params.key?(:branded_email_layout) + + branded_email_layout = normalized_branded_email_layout + + unless Current.account.feature_enabled?(:branded_email_templates) + return true if branded_email_layout.blank? + + render_could_not_create_error('Branded email templates feature is not enabled') + return false + end + + unless @inbox.email? + return true if branded_email_layout.blank? + + render_could_not_create_error('Branded email layout is only supported for email inboxes') + return false + end + + @inbox.update_branded_email_layout!(branded_email_layout) + true + rescue ActiveRecord::RecordInvalid => e + render_could_not_create_error(e.record.errors.full_messages.join(', ')) + false + end + + def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout] + def inbox_attributes [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, diff --git a/app/controllers/api/v1/accounts/integrations/base_controller.rb b/app/controllers/api/v1/accounts/integrations/base_controller.rb new file mode 100644 index 000000000..ef1ebb713 --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/base_controller.rb @@ -0,0 +1,9 @@ +class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController + private + + # Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy. + # Subclasses opt in per action with `before_action :check_authorization, only: [...]`. + def check_authorization + authorize(:hook) + end +end diff --git a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb index 087a9b78d..aec105930 100644 --- a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController before_action :fetch_hook, except: [:create] before_action :check_authorization @@ -35,10 +35,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base @hook = Current.account.hooks.find(params[:id]) end - def check_authorization - authorize(:hook) - end - def permitted_params params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {}) end diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index 9ca0c72fd..8ae3109b9 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -1,6 +1,7 @@ -class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_hook, only: [:destroy] + before_action :check_authorization, only: [:destroy] def destroy revoke_linear_token diff --git a/app/controllers/api/v1/accounts/integrations/notion_controller.rb b/app/controllers/api/v1/accounts/integrations/notion_controller.rb index ecf6bae6e..29343e4f5 100644 --- a/app/controllers/api/v1/accounts/integrations/notion_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/notion_controller.rb @@ -1,5 +1,6 @@ -class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController before_action :fetch_hook, only: [:destroy] + before_action :check_authorization, only: [:destroy] def destroy @hook.destroy! diff --git a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb index 7fe31889b..c847a85df 100644 --- a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb @@ -1,7 +1,8 @@ -class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController include Shopify::IntegrationHelper before_action :setup_shopify_context, only: [:orders] before_action :fetch_hook, except: [:auth] + before_action :check_authorization, only: [:destroy] before_action :validate_contact, only: [:orders] def auth diff --git a/app/controllers/api/v1/accounts/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb index 6889d30a4..f678fee42 100644 --- a/app/controllers/api/v1/accounts/labels_controller.rb +++ b/app/controllers/api/v1/accounts/labels_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController - before_action :current_account before_action :fetch_label, except: [:index, :create] before_action :check_authorization diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index c74c0ecfc..bdaf82f6c 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -81,7 +81,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController :name, :page_title, :slug, :archived, { config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] }, { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }, - { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] } + { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }, + { popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] } ) end @@ -89,6 +90,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController params.dig(:portal, :config, :locale_translations)&.keys || [] end + def popular_content_keys + params.dig(:portal, :config, :popular_content)&.keys || [] + end + def live_chat_widget_params permitted_params = params.permit(:inbox_id) return {} unless permitted_params.key?(:inbox_id) diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index 580ae77c6..46d89a1ba 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController + before_action :ensure_embedded_signup_enabled # Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins. before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? } before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } @@ -18,6 +19,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: private + def ensure_embedded_signup_enabled + return unless ChatwootApp.chatwoot_cloud? + return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation') + + raise Pundit::NotAuthorizedError + end + def process_embedded_signup service = Whatsapp::EmbeddedSignupService.new( account: Current.account, @@ -44,8 +52,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: def can_reconfigure_channel? channel = @inbox.channel return false unless channel.provider == 'whatsapp_cloud' - - # Reconfiguring a live embedded-signup channel requires the feature flag. + return true if ChatwootApp.chatwoot_cloud? return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup' true diff --git a/app/controllers/concerns/portal_home_data.rb b/app/controllers/concerns/portal_home_data.rb index 633071301..71eaad081 100644 --- a/app/controllers/concerns/portal_home_data.rb +++ b/app/controllers/concerns/portal_home_data.rb @@ -4,17 +4,53 @@ module PortalHomeData private def load_home_data - base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) + load_recommended_content + # The classic hero only needs the recommendations above; the rest is + # documentation-layout home data (also used on custom-domain home pages). + return unless @portal.layout == 'documentation' + @visible_categories = @portal.categories .where(locale: @locale) .joins(:articles).where(articles: { status: :published }) .order(position: :asc) .group('categories.id') - @popular_topics = @visible_categories.first(3) + @popular_topics = @recommended_categories.presence || @visible_categories.first(3) @featured = base_articles.order_by_views.limit(6) @category_contributors = build_category_contributors(@visible_categories) end + def load_recommended_content + @recommended_categories = recommended_categories + @recommended_articles = recommended_articles + end + + def base_articles + @base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category) + end + + # Admin-recommended categories for the locale, in the chosen order. Unlike the + # position-based fallback, published articles aren't required: the admin's pick wins. + def recommended_categories + ids = @portal.popular_category_ids(@locale) + ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids) + end + + # Admin-recommended articles for the locale, in the chosen order, limited to + # published articles that still exist. + def recommended_articles + ids = @portal.popular_article_ids(@locale) + ordered_by_ids(base_articles.where(id: ids), ids) + end + + # Loads the scope and returns its records ordered to match `ids`, dropping any + # that no longer exist. Skips the query entirely when `ids` is blank. + def ordered_by_ids(scope, ids) + return [] if ids.blank? + + by_id = scope.index_by(&:id) + ids.filter_map { |id| by_id[id] } + end + def build_category_contributors(categories) category_ids = categories.map(&:id) return {} if category_ids.empty? diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index a369830b6..a72687b42 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -64,10 +64,8 @@ class DashboardController < ActionController::Base return unless @portal @locale = @portal.default_locale - if @portal.layout == 'documentation' - request.variant = :documentation - load_home_data - end + request.variant = :documentation if @portal.layout == 'documentation' + load_home_data render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return end diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index 4982278d7..da7d9e6a5 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -7,7 +7,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl before_action :set_portal_layout before_action :set_view_variant before_action :ensure_portal_feature_enabled - before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' } + before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled } layout 'portal' def show diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index ee71f3c92..8fd18678c 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata) return if metadata.blank? - phone_number = normalized_phone_number(metadata[:display_phone_number]) - phone_number_id = metadata[:phone_number_id] - channel = Channel::Whatsapp.find_by(phone_number: phone_number) - - return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id - end - - def normalized_phone_number(phone_number) - return if phone_number.blank? - - phone_number = phone_number.to_s - phone_number.start_with?('+') ? phone_number : "+#{phone_number}" + Whatsapp::WebhookChannelFinderService.new( + display_phone_number: metadata[:display_phone_number], + phone_number_id: metadata[:phone_number_id] + ).perform end def inactive_whatsapp_number? diff --git a/app/drops/inbox_drop.rb b/app/drops/inbox_drop.rb index 563916d5f..29698fb4b 100644 --- a/app/drops/inbox_drop.rb +++ b/app/drops/inbox_drop.rb @@ -2,4 +2,18 @@ class InboxDrop < BaseDrop def name @obj.try(:name) end + + def business_name + @obj.try(:sanitized_business_name) + end + + def avatar_url + @obj.try(:avatar_url) + end + + def email + return unless @obj.try(:email?) + + @obj.try(:email_address).presence || @obj.try(:channel).try(:email) + end end diff --git a/app/javascript/dashboard/api/calls.js b/app/javascript/dashboard/api/calls.js new file mode 100644 index 000000000..aa220bdee --- /dev/null +++ b/app/javascript/dashboard/api/calls.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class CallsAPI extends ApiClient { + constructor() { + super('calls', { accountScoped: true }); + } + + get(params = {}) { + return axios.get(this.url, { params }); + } +} + +export default new CallsAPI(); diff --git a/app/javascript/dashboard/api/captain/agentSessions.js b/app/javascript/dashboard/api/captain/agentSessions.js new file mode 100644 index 000000000..a557b5938 --- /dev/null +++ b/app/javascript/dashboard/api/captain/agentSessions.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CaptainAgentSessions extends ApiClient { + constructor() { + super('captain/agent_sessions', { accountScoped: true }); + } +} + +export default new CaptainAgentSessions(); diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js index 1fc17798d..806b45bb2 100644 --- a/app/javascript/dashboard/api/captain/assistant.js +++ b/app/javascript/dashboard/api/captain/assistant.js @@ -26,15 +26,25 @@ class CaptainAssistant extends ApiClient { }); } - getStats({ assistantId, range }) { - return axios.get(`${this.url}/${assistantId}/stats`, { + getMetrics({ assistantId, range, signal }) { + const requestConfig = { params: { range, timezone_offset: getTimezoneOffset() }, - }); + }; + if (signal) requestConfig.signal = signal; + + return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig); } - getSummary({ assistantId, range }) { + getFaqStats({ assistantId, signal }) { + const requestConfig = {}; + if (signal) requestConfig.signal = signal; + + return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig); + } + + getSummary({ assistantId, range, stats }) { return axios.get(`${this.url}/${assistantId}/summary`, { - params: { range, timezone_offset: getTimezoneOffset() }, + params: { range, timezone_offset: getTimezoneOffset(), stats }, }); } diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js index ec24aae34..d458c1e5d 100644 --- a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js +++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js @@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient { return axios.get(`${this.url}/${callId}`).then(r => r.data); } - initiate(conversationId, sdpOffer) { + // Either conversationId, or contactId + inboxId to let the BE resolve the conversation. + initiate({ conversationId, contactId, inboxId }, sdpOffer) { return axios .post(`${this.url}/initiate`, { conversation_id: conversationId, + contact_id: contactId, + inbox_id: inboxId, sdp_offer: sdpOffer, }) .then(r => r.data); diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js index 55b620d1a..610ba1e57 100644 --- a/app/javascript/dashboard/api/helpCenter/articles.js +++ b/app/javascript/dashboard/api/helpCenter/articles.js @@ -17,6 +17,7 @@ class ArticlesAPI extends PortalsAPI { categorySlug, sort, query, + signal, }) { const url = getArticleSearchURL({ pageNumber, @@ -30,7 +31,7 @@ class ArticlesAPI extends PortalsAPI { host: this.url, }); - return axios.get(url); + return axios.get(url, { signal }); } searchArticles({ portalSlug, query }) { diff --git a/app/javascript/dashboard/api/specs/article.spec.js b/app/javascript/dashboard/api/specs/article.spec.js index b40613739..9f7052ed5 100644 --- a/app/javascript/dashboard/api/specs/article.spec.js +++ b/app/javascript/dashboard/api/specs/article.spec.js @@ -37,7 +37,8 @@ describe('#PortalAPI', () => { authorId: '1', }); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1' + '/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1', + { signal: undefined } ); }); }); diff --git a/app/javascript/dashboard/components-next/Calls/CallListItem.vue b/app/javascript/dashboard/components-next/Calls/CallListItem.vue new file mode 100644 index 000000000..dd2b3af37 --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallListItem.vue @@ -0,0 +1,240 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue new file mode 100644 index 000000000..6eaa81b7b --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue new file mode 100644 index 000000000..b0da9c41e --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue @@ -0,0 +1,56 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue new file mode 100644 index 000000000..f57d154eb --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue @@ -0,0 +1,34 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue new file mode 100644 index 000000000..4d01b0500 --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue @@ -0,0 +1,250 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/constants.js b/app/javascript/dashboard/components-next/Calls/constants.js new file mode 100644 index 000000000..f7399bfef --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/constants.js @@ -0,0 +1,51 @@ +import { + VOICE_CALL_STATUS, + VOICE_CALL_DIRECTION, +} from 'dashboard/components-next/message/constants'; + +export const CALL_KIND = { + ONGOING: 'ongoing', + INCOMING: 'incoming', + OUTGOING: 'outgoing', + MISSED: 'missed', + NO_REPLY: 'no_reply', + FAILED: 'failed', +}; + +// The API returns display values: status (ringing/in-progress/completed/ +// no-answer/failed) and direction (inbound/outbound). The list UI presents +// them as a single "kind" per row. +export const getCallKind = call => { + if ( + [VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes( + call.status + ) + ) { + return CALL_KIND.ONGOING; + } + if ( + [VOICE_CALL_STATUS.FAILED, VOICE_CALL_STATUS.REJECTED].includes(call.status) + ) { + return CALL_KIND.FAILED; + } + const isInbound = call.direction === VOICE_CALL_DIRECTION.INBOUND; + if (call.status === VOICE_CALL_STATUS.NO_ANSWER) { + return isInbound ? CALL_KIND.MISSED : CALL_KIND.NO_REPLY; + } + return isInbound ? CALL_KIND.INCOMING : CALL_KIND.OUTGOING; +}; + +// Filter chips map to the status/direction params supported by CallFinder. +export const CALL_ACTIVITY_PARAMS = { + missed: { + status: VOICE_CALL_STATUS.NO_ANSWER, + direction: VOICE_CALL_DIRECTION.INBOUND, + }, + no_reply: { + status: VOICE_CALL_STATUS.NO_ANSWER, + direction: VOICE_CALL_DIRECTION.OUTBOUND, + }, + incoming: { direction: VOICE_CALL_DIRECTION.INBOUND }, + outgoing: { direction: VOICE_CALL_DIRECTION.OUTBOUND }, + in_progress: { status: VOICE_CALL_STATUS.IN_PROGRESS }, +}; diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index 7e2b6f0c4..5c7ec691d 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { useCallsStore } from 'dashboard/stores/calls'; import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession'; -import ContactAPI from 'dashboard/api/contacts'; import Button from 'dashboard/components-next/button/Button.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; @@ -83,39 +82,18 @@ const navigateToConversation = conversationId => { const whatsappCallSession = useWhatsappCallSession(); -// Find the most recent open conversation for this contact in the picked inbox. -// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path). -// Pass inboxId so the BE applies the filter before the 20-row cap — without it, -// contacts whose latest WhatsApp conversation falls outside the 20 most recent -// across all inboxes would be treated as having no conversation. -const findWhatsappConversationId = async inboxId => { - const { data } = await ContactAPI.getConversations(props.contactId, { - inboxId, - }); - const conversations = data?.payload || []; - const match = [...conversations].sort( - (a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0) - )[0]; - return match?.id || null; -}; - const startWhatsappCall = async (inboxId, conversationIdHint) => { - // WhatsApp /initiate is conversation-scoped, so we must hand it a - // conversation. Use the caller's hint when given (in-conversation flow); - // otherwise pick the most recent one in the inbox. - const conversationId = - conversationIdHint || (await findWhatsappConversationId(inboxId)); - if (!conversationId) { - useAlert(t('CONTACT_PANEL.CALL_FAILED')); - return; - } - - const response = - await whatsappCallSession.initiateOutboundCall(conversationId); + const response = await whatsappCallSession.initiateOutboundCall( + conversationIdHint + ? { conversationId: conversationIdHint } + : { contactId: props.contactId, inboxId } + ); // The composable returns { status: 'locked' } when an init is already in // flight or a call is already active; treat that as a soft no-op rather than // claiming success. if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; + + const conversationId = response?.conversation_id || conversationIdHint; if (!response?.id) { // Permission template path returns no call id. Mirror the header button and // surface whether the request was just sent or is already pending instead of diff --git a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue index 259328bec..a27fbdac0 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue @@ -56,6 +56,9 @@ const localeMenuLabels = computed(() => ({ 'customize-content': t( 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT' ), + 'select-popular-content': t( + 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT' + ), delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'), })); diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue index 66d389ead..ba2ffa22e 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue @@ -2,6 +2,7 @@ import { ref } from 'vue'; import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue'; import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue'; +import PopularContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue'; import { useStore } from 'dashboard/composables/store'; import { useAlert, useTrack } from 'dashboard/composables'; import { useUISettings } from 'dashboard/composables/useUISettings'; @@ -26,6 +27,7 @@ const route = useRoute(); const { uiSettings, updateUISettings } = useUISettings(); const contentDialogRef = ref(null); +const popularContentDialogRef = ref(null); const isLocaleDefault = code => { return props.portal?.meta?.default_locale === code; @@ -154,6 +156,8 @@ const handleAction = ({ action }, localeCode) => { publishLocale({ localeCode: localeCode }); } else if (action === 'customize-content') { contentDialogRef.value.openForLocale(localeCode); + } else if (action === 'select-popular-content') { + popularContentDialogRef.value.openForLocale(localeCode); } else if (action === 'delete') { deletePortalLocale({ localeCode: localeCode }); } @@ -174,5 +178,6 @@ const handleAction = ({ action }, localeCode) => { @action="handleAction($event, locale.code)" /> + diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue new file mode 100644 index 000000000..487b657bb --- /dev/null +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue @@ -0,0 +1,235 @@ + + + diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue index 02a00c703..2446e0e2b 100644 --- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue +++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue @@ -234,6 +234,7 @@ onMounted(() => resetContacts()); ref="popoverRef" :align="align" :show-content-border="false" + :close-on-scroll="false" @show="onPopoverShow" @hide="onPopoverHide" > diff --git a/app/javascript/dashboard/components-next/audio/AudioPlayer.vue b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue new file mode 100644 index 000000000..c716ffbfb --- /dev/null +++ b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue index cf66a0a2f..9a68b71ce 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue @@ -9,6 +9,7 @@ const props = defineProps({ // null = neutral, true = good direction, false = bad direction trendGood: { type: Boolean, default: null }, clickable: { type: Boolean, default: false }, + loading: { type: Boolean, default: false }, }); const emit = defineEmits(['click']); @@ -45,7 +46,11 @@ const onActivate = () => { class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100" /> -
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue index df336a7e4..5e868956f 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue @@ -9,6 +9,10 @@ const props = defineProps({ type: String, default: '30', }, + stats: { + type: Object, + default: null, + }, }); const route = useRoute(); @@ -20,22 +24,41 @@ const assistantId = computed(() => route.params.assistantId); const welcomeMarkdown = ref(''); const isLoading = ref(false); +// Increments on every fetch so a slow response for a superseded +// range/stats/assistant can't overwrite the latest request's state. +let fetchToken = 0; + const fetchSummary = async () => { + fetchToken += 1; + const token = fetchToken; + + if (!props.stats) { + welcomeMarkdown.value = ''; + isLoading.value = false; + return; + } + isLoading.value = true; + let message = ''; try { const { data } = await CaptainAssistant.getSummary({ assistantId: assistantId.value, range: props.range, + stats: props.stats, }); - welcomeMarkdown.value = data.message ?? ''; + message = data.message ?? ''; } catch { - welcomeMarkdown.value = ''; - } finally { - isLoading.value = false; + message = ''; } + + if (token !== fetchToken) return; + welcomeMarkdown.value = message; + isLoading.value = false; }; -watch([() => props.range, assistantId], fetchSummary, { immediate: true }); +watch([() => props.range, () => props.stats, assistantId], fetchSummary, { + immediate: true, +}); // Render through the shared markdown formatter (html disabled, so it is safe) // used everywhere else for Captain output, instead of a bespoke parser. It diff --git a/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue b/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue index 1ab9e9503..2737cb353 100644 --- a/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue +++ b/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue @@ -1,6 +1,8 @@ + + diff --git a/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js b/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js new file mode 100644 index 000000000..b07868863 --- /dev/null +++ b/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js @@ -0,0 +1,224 @@ +import { mount } from '@vue/test-utils'; +import { h } from 'vue'; +import ReorderableMultiSelect from '../ReorderableMultiSelect.vue'; + +const OPTIONS = [ + { value: 1, label: 'Getting started', subtitle: 'Guides' }, + { value: 2, label: 'Billing', subtitle: 'Payments' }, + { value: 3, label: 'Security' }, + { value: 4, label: 'API', icon: '🔌', iconColor: '#000' }, +]; + +// A findable dropdown stub that exposes the `focus()` the component calls on open. +const ComboBoxDropdownStub = { + name: 'ComboBoxDropdown', + props: [ + 'open', + 'options', + 'searchValue', + 'searchPlaceholder', + 'emptyState', + 'loading', + ], + emits: ['select', 'update:searchValue'], + methods: { focus() {} }, + template: '
', +}; + +// Renders a real ', +}; + +const mountSelect = (props = {}, slots = {}) => + mount(ReorderableMultiSelect, { + props: { options: OPTIONS, max: 3, ...props }, + slots, + global: { + stubs: { + Button: ButtonStub, + ComboBoxDropdown: ComboBoxDropdownStub, + Spinner: true, + Icon: true, + EmojiIcon: true, + OnClickOutside: { template: '
' }, + }, + }, + }); + +const dropdown = wrapper => wrapper.findComponent(ComboBoxDropdownStub); +const addTrigger = wrapper => + wrapper.findAll('button').find(button => !button.attributes('data-icon')); +const removeButtons = wrapper => + wrapper.findAll('button[data-icon="i-lucide-x"]'); +const rows = wrapper => wrapper.findAll('[draggable="true"]'); +const lastModel = wrapper => wrapper.emitted('update:modelValue')?.at(-1)?.[0]; + +describe('ReorderableMultiSelect', () => { + describe('rendering selected rows', () => { + it('renders rows in model order with labels resolved from options', () => { + const wrapper = mountSelect({ modelValue: [2, 1] }); + + const labels = rows(wrapper).map(row => row.find('p').text()); + expect(labels).toEqual(['Billing', 'Getting started']); + }); + + it('falls back to the stringified id when an option is unknown', () => { + const wrapper = mountSelect({ modelValue: [99] }); + + expect(rows(wrapper)[0].find('p').text()).toBe('99'); + }); + + it('renders the progress dots filled up to the selection count', () => { + const wrapper = mountSelect({ + modelValue: [1, 2], + max: 3, + label: 'Tags', + }); + + const filled = wrapper.findAll('.bg-n-brand').length; + expect(filled).toBe(2); + }); + + it('exposes remaining and max to the counter slot', () => { + const wrapper = mountSelect( + { modelValue: [1], max: 3 }, + { counter: ({ remaining, max }) => h('span', `${remaining}/${max}`) } + ); + + expect(wrapper.text()).toContain('2/3'); + }); + }); + + describe('adding options', () => { + it('appends the chosen option to the model', () => { + const wrapper = mountSelect({ modelValue: [1] }); + + dropdown(wrapper).vm.$emit('select', OPTIONS[1]); + + expect(lastModel(wrapper)).toEqual([1, 2]); + }); + + it('hides the add trigger once the model reaches max', () => { + const wrapper = mountSelect({ modelValue: [1, 2], max: 2 }); + + expect(addTrigger(wrapper)).toBeUndefined(); + expect(dropdown(wrapper).exists()).toBe(false); + }); + + it('closes the dropdown when the last slot is filled', async () => { + const wrapper = mountSelect({ modelValue: [1], max: 2 }); + await addTrigger(wrapper).trigger('click'); + expect(dropdown(wrapper).props('open')).toBe(true); + + dropdown(wrapper).vm.$emit('select', OPTIONS[1]); + await wrapper.vm.$nextTick(); + + // Reaching max removes the trigger (and its dropdown) entirely. + expect(dropdown(wrapper).exists()).toBe(false); + }); + + it('excludes already-selected options from the dropdown', () => { + const wrapper = mountSelect({ modelValue: [1] }); + + const values = dropdown(wrapper) + .props('options') + .map(option => option.value); + expect(values).toEqual([2, 3, 4]); + }); + }); + + describe('removing options', () => { + it('removes the clicked item from the model', async () => { + const wrapper = mountSelect({ modelValue: [1, 2, 3] }); + + await removeButtons(wrapper)[1].trigger('click'); + + expect(lastModel(wrapper)).toEqual([1, 3]); + }); + }); + + describe('searching', () => { + it('filters options locally by label', async () => { + const wrapper = mountSelect({ modelValue: [] }); + + dropdown(wrapper).vm.$emit('update:searchValue', 'bill'); + await wrapper.vm.$nextTick(); + + const values = dropdown(wrapper) + .props('options') + .map(option => option.value); + expect(values).toEqual([2]); + }); + + it('emits search and skips local filtering when serverSearch is set', async () => { + const wrapper = mountSelect({ modelValue: [], serverSearch: true }); + + dropdown(wrapper).vm.$emit('update:searchValue', 'bill'); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('search').at(-1)).toEqual(['bill']); + // All unselected options remain; the parent owns filtering. + expect(dropdown(wrapper).props('options')).toHaveLength(4); + }); + + it('emits an empty search when the trigger opens', async () => { + const wrapper = mountSelect({ modelValue: [1] }); + + await addTrigger(wrapper).trigger('click'); + + expect(wrapper.emitted('search').at(-1)).toEqual(['']); + expect(dropdown(wrapper).props('open')).toBe(true); + }); + }); + + describe('reordering', () => { + it('moves a row to the dropped position within the model', async () => { + const wrapper = mountSelect({ modelValue: [1, 2, 3] }); + + await rows(wrapper)[0].trigger('dragstart'); + await rows(wrapper)[2].trigger('dragover'); + + expect(lastModel(wrapper)).toEqual([2, 3, 1]); + }); + }); + + describe('loading state', () => { + it('shows skeleton rows when loading a non-empty, closed selection', () => { + const wrapper = mountSelect({ modelValue: [1, 2], loading: true }); + + const skeleton = wrapper.find('[aria-busy="true"]'); + expect(skeleton.exists()).toBe(true); + expect(skeleton.findAll('.animate-pulse').length).toBeGreaterThan(0); + }); + + it('does not show skeletons when the selection is empty', () => { + const wrapper = mountSelect({ modelValue: [], loading: true }); + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false); + }); + + it('shows the real rows, not skeletons, while searching in an open dropdown', async () => { + // Open first (trigger is enabled), then a live search turns loading on. + const wrapper = mountSelect({ modelValue: [1, 2] }); + await addTrigger(wrapper).trigger('click'); + + await wrapper.setProps({ loading: true }); + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false); + expect(rows(wrapper)).toHaveLength(2); + }); + + it('forwards loading to the dropdown and disables the closed trigger', () => { + const wrapper = mountSelect({ modelValue: [1], loading: true }); + + expect(dropdown(wrapper).props('loading')).toBe(true); + expect(addTrigger(wrapper).attributes('disabled')).toBeDefined(); + }); + }); +}); diff --git a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue index 4d526d558..1390ea0f7 100644 --- a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue +++ b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue @@ -3,8 +3,13 @@ import { ref, computed, onMounted, watch } from 'vue'; import { useVuelidate } from '@vuelidate/core'; import { requiredIf } from '@vuelidate/validators'; import { useI18n } from 'vue-i18n'; -import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper'; -import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages'; +import { + isTwilioComplete, + isTwilioMediaTemplate, + getTwilioMediaVariableKey, + getTwilioMediaUrl, + applyTwilioMediaFilename, +} from '@chatwoot/utils'; import Input from 'dashboard/components-next/input/Input.vue'; @@ -40,30 +45,23 @@ const templateBody = computed(() => { return props.template.body || ''; }); -const hasMediaTemplate = computed(() => { - return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA; -}); +// Media-template detection and variable extraction are shared with the mobile +// app via @chatwoot/utils. +const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template)); const hasVariables = computed(() => { return templateBody.value?.match(VARIABLE_PATTERN) !== null; }); -const mediaVariableKey = computed(() => { - if (!hasMediaTemplate.value) return null; - const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0]; - if (!mediaUrl) return null; - return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null; -}); +const mediaVariableKey = computed(() => + getTwilioMediaVariableKey(props.template) +); -const hasMediaVariable = computed(() => { - return hasMediaTemplate.value && mediaVariableKey.value !== null; -}); +const hasMediaVariable = computed(() => mediaVariableKey.value !== null); -const templateMediaUrl = computed(() => { - if (!hasMediaTemplate.value) return ''; - - return props.template?.types?.['twilio/media']?.media?.[0] || ''; -}); +const templateMediaUrl = computed(() => + hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : '' +); const variablePattern = computed(() => { if (!hasVariables.value) return []; @@ -83,26 +81,10 @@ const renderedTemplate = computed(() => { return rendered; }); -const isFormInvalid = computed(() => { - if (!hasVariables.value && !hasMediaVariable.value) return false; - - if (hasVariables.value) { - const hasEmptyVariable = variablePattern.value.some( - variable => !processedParams.value[variable] - ); - if (hasEmptyVariable) return true; - } - - if ( - hasMediaVariable.value && - mediaVariableKey.value && - !processedParams.value[mediaVariableKey.value] - ) { - return true; - } - - return false; -}); +// Completeness validation is shared with the mobile app via @chatwoot/utils. +const isFormInvalid = computed( + () => !isTwilioComplete(props.template, processedParams.value) +); const v$ = useVuelidate( { @@ -135,19 +117,11 @@ const sendMessage = () => { const { friendly_name, language } = props.template; - // Process parameters and extract filename from media URL if needed - const processedParameters = { ...processedParams.value }; - - // For media templates, extract filename from full URL - if ( - hasMediaVariable.value && - mediaVariableKey.value && - processedParameters[mediaVariableKey.value] - ) { - processedParameters[mediaVariableKey.value] = extractFilenameFromUrl( - processedParameters[mediaVariableKey.value] - ); - } + // For media templates, reduce the media URL to a filename before sending. + const processedParameters = applyTwilioMediaFilename( + props.template, + processedParams.value + ); const payload = { message: renderedTemplate.value, diff --git a/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue b/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue new file mode 100644 index 000000000..752616109 --- /dev/null +++ b/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue @@ -0,0 +1,342 @@ + + + diff --git a/app/javascript/dashboard/components-next/message/bubbles/Base.vue b/app/javascript/dashboard/components-next/message/bubbles/Base.vue index 457b583ea..e799bd755 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Base.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Base.vue @@ -2,6 +2,7 @@ import { computed } from 'vue'; import MessageMeta from '../MessageMeta.vue'; +import CaptainGenerationDetails from '../CaptainGenerationDetails.vue'; import { emitter } from 'shared/helpers/mitt'; import { useMessageContext } from '../provider.js'; @@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n'; import MessageFormatter from 'shared/helpers/MessageFormatter.js'; import { BUS_EVENTS } from 'shared/constants/busEvents'; -import { MESSAGE_VARIANTS, ORIENTATION } from '../constants'; +import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants'; const props = defineProps({ hideMeta: { type: Boolean, default: false }, }); -const { variant, orientation, inReplyTo, shouldGroupWithNext } = - useMessageContext(); +const { + variant, + orientation, + inReplyTo, + shouldGroupWithNext, + id, + sender, + senderType, +} = useMessageContext(); const { t } = useI18n(); +const isCaptainMessage = computed( + () => + (sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT +); + +const metaColorClass = computed(() => + variant.value === MESSAGE_VARIANTS.PRIVATE + ? 'text-n-amber-12/50' + : 'text-n-slate-11' +); + +const emailMetaClass = computed(() => + variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '' +); + const varaintBaseMap = { [MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12', [MESSAGE_VARIANTS.PRIVATE]: @@ -114,16 +137,21 @@ const replyToPreview = computed(() => { />
- +
diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index 34eb1eaff..1a127d9e6 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -263,9 +263,9 @@ const handleCallBack = async () => { if (!canCallBack.value || isInitiatingCall.value) return; try { if (isWhatsapp.value) { - const response = await whatsappCallSession.initiateOutboundCall( - conversationId.value - ); + const response = await whatsappCallSession.initiateOutboundCall({ + conversationId: conversationId.value, + }); if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; // Permission template path returns no call id — show banner, no widget yet. if (!response?.id) { diff --git a/app/javascript/dashboard/components-next/popover/Popover.vue b/app/javascript/dashboard/components-next/popover/Popover.vue index 5b67e572f..9d369133f 100644 --- a/app/javascript/dashboard/components-next/popover/Popover.vue +++ b/app/javascript/dashboard/components-next/popover/Popover.vue @@ -1,7 +1,11 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/calls/routes.js b/app/javascript/dashboard/routes/dashboard/calls/routes.js new file mode 100644 index 000000000..fa952a749 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/calls/routes.js @@ -0,0 +1,22 @@ +import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes'; +import { + CONVERSATION_PERMISSIONS, + ROLES, +} from 'dashboard/constants/permissions'; +import { frontendURL } from '../../../helper/URLHelper'; +import CallsIndex from './pages/CallsIndex.vue'; + +export const routes = [ + { + path: frontendURL('accounts/:accountId/calls'), + name: 'calls_dashboard_index', + component: CallsIndex, + meta: { + permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], + installationTypes: [ + INSTALLATION_TYPES.CLOUD, + INSTALLATION_TYPES.ENTERPRISE, + ], + }, + }, +]; diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue index 29411e56d..124b7c924 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue @@ -1,5 +1,5 @@