diff --git a/.bundler-audit.yml b/.bundler-audit.yml index 0a6c574ab..ffbfa18e0 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -2,6 +2,9 @@ ignore: - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) - GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+) + # Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor. + # Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable. + - GHSA-jp94-3292-c3xv # Rails 7.1 has no patched release for the Active Storage proxy range # advisories. Chatwoot limits proxy range requests locally. - CVE-2026-33658 diff --git a/.env.example b/.env.example index bc7380a29..69b1b9cde 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,8 @@ SMTP_OPENSSL_VERIFY_MODE=peer # Mail Incoming # This is the domain set for the reply emails when conversation continuity is enabled MAILER_INBOUND_EMAIL_DOMAIN= +# Maximum time in seconds to process a single IMAP email +# EMAIL_PROCESSING_TIMEOUT_SECONDS=60 # Set this to the appropriate ingress channel with regards to incoming emails # Possible values are : # relay for Exim, Postfix, Qmail @@ -232,6 +234,10 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: # Comma-separated list of trusted IPs that bypass Rack Attack throttling rules # RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10 +## SafeFetch private network access +## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs. +# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false + ## Running chatwoot as an API only server ## setting this value to true will disable the frontend dashboard endpoints # CW_API_ONLY_SERVER=false diff --git a/Gemfile b/Gemfile index dfcdb3e30..680a0738b 100644 --- a/Gemfile +++ b/Gemfile @@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0' # two-factor authentication gem 'devise-two-factor', '>= 5.0.0' # authorization -gem 'jwt' +gem 'jwt', '~> 2.10', '>= 2.10.3' gem 'pundit' # super admin @@ -133,9 +133,9 @@ gem 'sentry-ruby', require: false gem 'sentry-sidekiq', '>= 5.19.0', require: false ##-- background job processing --## -gem 'sidekiq', '>= 7.3.1' +gem 'sidekiq', '~> 7.3', '>= 7.3.1' # We want cron jobs -gem 'sidekiq-cron', '>= 1.12.0' +gem 'sidekiq-cron', '>= 2.4.0' # for sidekiq healthcheck gem 'sidekiq_alive' @@ -209,6 +209,8 @@ gem 'opentelemetry-exporter-otlp' gem 'shopify_api' +gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl' + ### Gems required only in specific deployment environments ### ############################################################## diff --git a/Gemfile.lock b/Gemfile.lock index 21dfd3547..0479393b0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -196,6 +196,9 @@ GEM bigdecimal rexml crass (1.0.6) + cronex (0.15.0) + tzinfo + unicode (>= 0.4.4.5) csv (3.3.0) csv-safe (3.3.1) csv (~> 3.0) @@ -298,7 +301,7 @@ GEM railties (>= 5.0.0) faker (3.2.0) i18n (>= 1.8.11, < 2) - faraday (2.14.1) + faraday (2.14.2) faraday-net_http (>= 2.0, < 3.5) json logger @@ -336,6 +339,7 @@ GEM ffi-compiler (1.0.1) ffi (>= 1.0.0) rake + firecrawl-sdk (1.4.1) flag_shih_tzu (0.3.23) foreman (0.87.2) fugit (1.11.1) @@ -487,7 +491,7 @@ GEM judoscale-sidekiq (1.8.2) judoscale-ruby (= 1.8.2) sidekiq (>= 5.0) - jwt (2.10.1) + jwt (2.10.3) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -902,10 +906,11 @@ GEM logger rack (>= 2.2.4) redis-client (>= 0.22.2) - sidekiq-cron (1.12.0) - fugit (~> 1.8) + sidekiq-cron (2.4.0) + cronex (>= 0.13.0) + fugit (~> 1.8, >= 1.11.1) globalid (>= 1.0.1) - sidekiq (>= 6) + sidekiq (>= 6.5.0) sidekiq_alive (2.5.0) gserver (~> 0.0.1) sidekiq (>= 5, < 9) @@ -979,6 +984,7 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.8.2) + unicode (0.4.4.5) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) @@ -1074,6 +1080,7 @@ DEPENDENCIES faker faraday_middleware-aws-sigv4 fcm + firecrawl-sdk (~> 1.0) flag_shih_tzu foreman gemoji @@ -1095,7 +1102,7 @@ DEPENDENCIES json_schemer judoscale-rails judoscale-sidekiq - jwt + jwt (~> 2.10, >= 2.10.3) kaminari koala letter_opener @@ -1155,8 +1162,8 @@ DEPENDENCIES sentry-sidekiq (>= 5.19.0) shopify_api shoulda-matchers - sidekiq (>= 7.3.1) - sidekiq-cron (>= 1.12.0) + sidekiq (~> 7.3, >= 7.3.1) + sidekiq-cron (>= 2.4.0) sidekiq_alive simplecov (>= 0.21) simplecov_json_formatter diff --git a/VERSION_CW b/VERSION_CW index c412a4e2e..d2b9909a9 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.14.0 +4.14.1 diff --git a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb index b45c16828..7ea9cab5d 100644 --- a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb +++ b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb @@ -1,7 +1,7 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController before_action :portal before_action :check_authorization - before_action :set_articles, only: [:update_status, :delete_articles] + before_action :set_articles, only: [:update_status, :update_category, :delete_articles] def translate head :not_implemented @@ -19,6 +19,18 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba render_could_not_create_error(e.message) end + def update_category + return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none? + return render_could_not_create_error(I18n.t('portals.articles.category_not_found')) unless category_valid? + + ActiveRecord::Base.transaction do + @articles.find_each { |article| article.update!(category_id: params[:category_id]) } + end + head :ok + rescue ActiveRecord::RecordInvalid => e + render_could_not_create_error(e.message) + end + def delete_articles return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none? @@ -39,5 +51,9 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba def set_articles @articles = @portal.articles.where(id: params[:ids]) end + + def category_valid? + @portal.categories.exists?(id: params[:category_id]) + end end Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController') diff --git a/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb new file mode 100644 index 000000000..d9f15613b --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb @@ -0,0 +1,16 @@ +class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController + before_action :ensure_unread_counts_enabled + + def index + counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform + render json: { payload: counts } + end + + private + + def ensure_unread_counts_enabled + return if Current.account.feature_enabled?('conversation_unread_counts') + + render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden + end +end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 6cc77cd54..2856c7817 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -162,6 +162,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro # rubocop:disable Rails/SkipsModelValidations @conversation.update_columns(updates) # rubocop:enable Rails/SkipsModelValidations + + ::Conversations::UnreadCounts::Notifier.new(@conversation).perform end def should_update_last_seen? diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 27c4126a6..770018e3c 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -78,7 +78,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def portal_params params.require(:portal).permit( :id, :color, :custom_domain, :header_text, :homepage_link, - :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] } + :name, :page_title, :slug, :archived, + { config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] }, + { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] } ) end diff --git a/app/controllers/api/v1/accounts/working_hours_controller.rb b/app/controllers/api/v1/accounts/working_hours_controller.rb deleted file mode 100644 index 96d98293a..000000000 --- a/app/controllers/api/v1/accounts/working_hours_controller.rb +++ /dev/null @@ -1,18 +0,0 @@ -class Api::V1::Accounts::WorkingHoursController < Api::V1::Accounts::BaseController - before_action :check_authorization - before_action :fetch_webhook, only: [:update] - - def update - @working_hour.update!(working_hour_params) - end - - private - - def working_hour_params - params.require(:working_hour).permit(:inbox_id, :open_hour, :open_minutes, :close_hour, :close_minutes, :closed_all_day) - end - - def fetch_working_hour - @working_hour = Current.account.working_hours.find(params[:id]) - end -end diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 2bbfafcc7..6a83d08fb 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -1,9 +1,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController - before_action :ensure_custom_domain_request, only: [:show, :index] + before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled before_action :set_category, except: [:index, :show, :tracking_pixel] - before_action :set_article, only: [:show] + before_action :set_article, only: [:show, :show_markdown] layout 'portal' def index @@ -21,6 +23,13 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def show @og_image_url = helpers.set_og_image_url(@portal.name, @article.title) + @parsed_content = render_article_content(@article.content.to_s) + end + + def show_markdown + return head :not_found unless @article&.published? + + render plain: @article.content.to_s, content_type: 'text/markdown; charset=utf-8' end def tracking_pixel @@ -62,7 +71,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def set_article @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) - @parsed_content = render_article_content(@article.content.to_s) end def set_category diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 46158bce9..2991b84d2 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -7,6 +7,8 @@ class Public::Api::V1::Portals::BaseController < PublicController around_action :set_locale after_action :allow_iframe_requests + PORTAL_LAYOUTS = %w[classic documentation].freeze + private def show_plain_layout @@ -17,6 +19,14 @@ class Public::Api::V1::Portals::BaseController < PublicController @theme_from_params = params[:theme] if %w[dark light].include?(params[:theme]) end + def set_portal_layout + @portal_layout = PORTAL_LAYOUTS.include?(@portal&.layout) ? @portal.layout : 'classic' + end + + def set_view_variant + request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled + end + def portal @portal ||= Portal.find_by!(slug: params[:slug], archived: false) end @@ -42,7 +52,7 @@ class Public::Api::V1::Portals::BaseController < PublicController article_locale = if article.category.present? article.category.locale else - article.portal.default_locale + article.locale end @locale = validate_and_get_locale(article_locale) I18n.with_locale(@locale, &) diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index 3fb200269..d1ebe92c6 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -1,12 +1,18 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show, :index] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled before_action :set_category, only: [:show] + before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' } layout 'portal' def index - @categories = @portal.categories.order(position: :asc) + respond_to do |format| + format.html { redirect_to public_portal_locale_path(@portal.slug, params[:locale]), status: :moved_permanently } + format.json { @categories = @portal.categories.order(position: :asc) } + end end def show @@ -21,4 +27,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals: Rails.logger.info "Category: not found for slug: #{params[:category_slug]}" render_404 && return if @category.blank? end + + def load_category_articles + @articles = @category.articles.published.order(:position).includes(:author) + @category_authors = @articles.filter_map(&:author).uniq + end end diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index a187ca8a8..63f44b052 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -2,7 +2,10 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl before_action :ensure_custom_domain_request, only: [:show] before_action :redirect_to_portal_with_locale, only: [:show] before_action :portal + 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' } layout 'portal' def show @@ -28,4 +31,28 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl portal redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" end + + def load_home_data + base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) + @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) + @featured = base_articles.order_by_views.limit(6) + @category_contributors = build_category_contributors(@visible_categories) + end + + def build_category_contributors(categories) + category_ids = categories.map(&:id) + return {} if category_ids.empty? + + @portal.articles + .published + .where(locale: @locale, category_id: category_ids) + .includes(:author) + .group_by(&:category_id) + .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } + end end diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index d607ba151..53075a555 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -31,7 +31,11 @@ class Twilio::CallbackController < ApplicationController :Latitude, :Longitude, :MessageType, - :ProfileName + :ProfileName, + :ExternalUserId, + :ParentExternalUserId, + :ProfileUsername, + :Username ) end end diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index 7416b7861..abf3ca354 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -17,6 +17,7 @@ class AsyncDispatcher < BaseDispatcher InstallationWebhookListener.instance, NotificationListener.instance, ParticipationListener.instance, + Conversations::UnreadCounts::Listener.instance, ReportingEventListener.instance, WebhookListener.instance ] diff --git a/app/helpers/file_type_helper.rb b/app/helpers/file_type_helper.rb index 03b807aad..6bae0d3d1 100644 --- a/app/helpers/file_type_helper.rb +++ b/app/helpers/file_type_helper.rb @@ -17,6 +17,7 @@ module FileTypeHelper def image_file?(content_type) [ 'image/jpeg', + 'image/jpg', 'image/png', 'image/gif', 'image/bmp', diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 15de0fbd7..65166145c 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -96,4 +96,15 @@ module PortalHelper colors[username.length % colors.size] end + + def format_authors_label(authors) + return if authors.blank? + + names = authors.map(&:available_name) + return names.to_sentence if names.size <= 3 + + I18n.t('public_portal.sidebar.authors_others', + names: names.first(2).join(', '), + count: authors.size - 2) + end end diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js new file mode 100644 index 000000000..ec24aae34 --- /dev/null +++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js @@ -0,0 +1,45 @@ +/* global axios */ +import ApiClient from '../../ApiClient'; + +class WhatsappCallsAPI extends ApiClient { + constructor() { + super('whatsapp_calls', { accountScoped: true }); + } + + show(callId) { + return axios.get(`${this.url}/${callId}`).then(r => r.data); + } + + initiate(conversationId, sdpOffer) { + return axios + .post(`${this.url}/initiate`, { + conversation_id: conversationId, + sdp_offer: sdpOffer, + }) + .then(r => r.data); + } + + accept(callId, sdpAnswer) { + return axios + .post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer }) + .then(r => r.data); + } + + reject(callId) { + return axios.post(`${this.url}/${callId}/reject`).then(r => r.data); + } + + terminate(callId) { + return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data); + } + + uploadRecording(callId, blob, filename = 'call-recording.webm') { + const formData = new FormData(); + formData.append('recording', blob, filename); + return axios + .post(`${this.url}/${callId}/upload_recording`, formData) + .then(r => r.data); + } +} + +export default new WhatsappCallsAPI(); diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index bae5623a7..c39a4cf9d 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -35,8 +35,9 @@ class ContactAPI extends ApiClient { return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data); } - getConversations(contactId) { - return axios.get(`${this.url}/${contactId}/conversations`); + getConversations(contactId, { inboxId } = {}) { + const params = inboxId ? { inbox_id: inboxId } : {}; + return axios.get(`${this.url}/${contactId}/conversations`, { params }); } getContactableInboxes(contactId) { @@ -47,9 +48,10 @@ class ContactAPI extends ApiClient { return axios.get(`${this.url}/${contactId}/labels`); } - initiateCall(contactId, inboxId) { + initiateCall(contactId, inboxId, conversationId = null) { return axios.post(`${this.url}/${contactId}/call`, { inbox_id: inboxId, + conversation_id: conversationId, }); } diff --git a/app/javascript/dashboard/api/conversations.js b/app/javascript/dashboard/api/conversations.js index 876103694..1de9aee29 100644 --- a/app/javascript/dashboard/api/conversations.js +++ b/app/javascript/dashboard/api/conversations.js @@ -13,6 +13,10 @@ class ConversationApi extends ApiClient { updateLabels(conversationID, labels) { return axios.post(`${this.url}/${conversationID}/labels`, { labels }); } + + getUnreadCounts() { + return axios.get(`${this.url}/unread_counts`); + } } export default new ConversationApi(); diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js index c79aa5da7..bab45bcb5 100644 --- a/app/javascript/dashboard/api/helpCenter/articles.js +++ b/app/javascript/dashboard/api/helpCenter/articles.js @@ -87,6 +87,13 @@ class ArticlesAPI extends PortalsAPI { ); } + bulkUpdateCategory({ portalSlug, articleIds, categoryId }) { + return axios.patch( + `${this.url}/${portalSlug}/articles/bulk_actions/update_category`, + { ids: articleIds, category_id: categoryId } + ); + } + bulkDelete({ portalSlug, articleIds }) { return axios.delete( `${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`, diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index cc564fe96..114dbb6f4 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -52,6 +52,14 @@ class Inboxes extends CacheEnabledApiClient { resetSecret(inboxId) { return axios.post(`${this.url}/${inboxId}/reset_secret`); } + + enableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`); + } + + disableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/specs/article.spec.js b/app/javascript/dashboard/api/specs/article.spec.js index 71128682c..b40613739 100644 --- a/app/javascript/dashboard/api/specs/article.spec.js +++ b/app/javascript/dashboard/api/specs/article.spec.js @@ -153,4 +153,33 @@ describe('#PortalAPI', () => { ); }); }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#bulkUpdateCategory', () => { + articlesAPI.bulkUpdateCategory({ + portalSlug: 'room-rental', + articleIds: [1, 2, 3], + categoryId: 7, + }); + expect(axiosMock.patch).toHaveBeenCalledWith( + '/api/v1/portals/room-rental/articles/bulk_actions/update_category', + { ids: [1, 2, 3], category_id: 7 } + ); + }); + }); }); diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index b21aeb102..f55ecdfaa 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -41,7 +41,8 @@ describe('#ContactsAPI', () => { it('#getConversations', () => { contactAPI.getConversations(1); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/contacts/1/conversations' + '/api/v1/contacts/1/conversations', + { params: {} } ); }); diff --git a/app/javascript/dashboard/api/specs/conversations.spec.js b/app/javascript/dashboard/api/specs/conversations.spec.js index 7ae4eb774..686db4098 100644 --- a/app/javascript/dashboard/api/specs/conversations.spec.js +++ b/app/javascript/dashboard/api/specs/conversations.spec.js @@ -11,6 +11,7 @@ describe('#ConversationApi', () => { expect(conversationsAPI).toHaveProperty('delete'); expect(conversationsAPI).toHaveProperty('getLabels'); expect(conversationsAPI).toHaveProperty('updateLabels'); + expect(conversationsAPI).toHaveProperty('getUnreadCounts'); }); describe('API calls', () => { @@ -47,5 +48,12 @@ describe('#ConversationApi', () => { } ); }); + + it('#getUnreadCounts', () => { + conversationsAPI.getUnreadCounts(); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/conversations/unread_counts' + ); + }); }); }); diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index b258dc763..7e2b6f0c4 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -3,10 +3,20 @@ import { computed, ref, useAttrs } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import { useMapGetter, useStore } from 'dashboard/composables/store'; -import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; +import { + isVoiceCallEnabled, + getVoiceCallProvider, + VOICE_CALL_PROVIDERS, +} from 'dashboard/helper/inbox'; +import { + VOICE_CALL_DIRECTION, + VOICE_CALL_OUTBOUND_INIT_STATUS, +} from 'dashboard/components-next/message/constants'; 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'; @@ -14,6 +24,9 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; const props = defineProps({ phone: { type: String, default: '' }, contactId: { type: [String, Number], required: true }, + // When set, the WhatsApp call continues in this conversation (matching the + // header button) instead of looking up the contact's most recent one. + conversationId: { type: [String, Number], default: null }, label: { type: String, default: '' }, icon: { type: [String, Object, Function], default: '' }, size: { type: String, default: 'sm' }, @@ -30,6 +43,7 @@ const { t } = useI18n(); const dialogRef = ref(null); +const callsStore = useCallsStore(); const inboxesList = useMapGetter('inboxes/getInboxes'); const contactsUiFlags = useMapGetter('contacts/getUIFlags'); @@ -38,13 +52,22 @@ const voiceInboxes = computed(() => ); const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0); -// Unified behavior: hide when no phone const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone); const isInitiatingCall = computed(() => { return contactsUiFlags.value?.isInitiatingCall || false; }); +// Mirror the conversation-header button: block a new call whenever any provider +// call is already active or ringing, otherwise starting a WhatsApp call here +// would leave a still-live Twilio (or other) session with no visible control. +const isCallButtonDisabled = computed( + () => + callsStore.hasActiveCall || + callsStore.hasIncomingCall || + isInitiatingCall.value +); + const navigateToConversation = conversationId => { const accountId = route.params.accountId; if (conversationId && accountId) { @@ -58,23 +81,96 @@ const navigateToConversation = conversationId => { } }; -const startCall = async inboxId => { - if (isInitiatingCall.value) return; +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); + // 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; + 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 + // claiming the call started. The permission message lands in the + // conversation, so still navigate there. + const messageKey = + response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING + ? 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_PENDING' + : 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_REQUESTED'; + useAlert(t(messageKey)); + navigateToConversation(conversationId); + return; + } + + // Stay non-active until the connect cable event arrives — flipping to active + // here would start the duration timer before the contact picks up. + callsStore.addCall({ + callSid: response.call_id, + callId: response.id, + conversationId, + inboxId, + callDirection: VOICE_CALL_DIRECTION.OUTBOUND, + provider: VOICE_CALL_PROVIDERS.WHATSAPP, + }); + + useAlert(t('CONTACT_PANEL.CALL_INITIATED')); + navigateToConversation(conversationId); +}; + +const startCall = async (inboxId, conversationIdHint = null) => { + if (isCallButtonDisabled.value) return; + + const inbox = (inboxesList.value || []).find(i => i.id === inboxId); + if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) { + try { + await startWhatsappCall(inboxId, conversationIdHint); + } catch (error) { + useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED')); + } + return; + } try { const response = await store.dispatch('contacts/initiateCall', { contactId: props.contactId, inboxId, + conversationId: conversationIdHint, }); const { call_sid: callSid, conversation_id: conversationId } = response; - // Add call to store immediately so widget shows - const callsStore = useCallsStore(); callsStore.addCall({ callSid, conversationId, inboxId, - callDirection: 'outbound', + callDirection: VOICE_CALL_DIRECTION.OUTBOUND, }); useAlert(t('CONTACT_PANEL.CALL_INITIATED')); @@ -86,6 +182,22 @@ const startCall = async inboxId => { }; const onClick = async () => { + // In conversation context, only stay in this conversation if its inbox is + // itself voice-capable (works the same for Twilio and WhatsApp). For + // non-voice channels (email, web, …) fall back to the picker so the call + // goes out via a voice inbox. + if (props.conversationId) { + const conversation = store.getters.getConversationById( + props.conversationId + ); + const conversationInbox = (inboxesList.value || []).find( + i => i.id === conversation?.inbox_id + ); + if (conversationInbox && isVoiceCallEnabled(conversationInbox)) { + await startCall(conversationInbox.id, props.conversationId); + return; + } + } if (voiceInboxes.value.length > 1) { dialogRef.value?.open(); return; @@ -106,7 +218,7 @@ const onPickInbox = async inbox => { v-if="shouldRender" v-tooltip.top-end="tooltipLabel || null" v-bind="attrs" - :disabled="isInitiatingCall" + :disabled="isCallButtonDisabled" :is-loading="isInitiatingCall" :label="label" :icon="icon" diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index 847bbd600..b12d0331a 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -142,29 +142,27 @@ watch( diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue index 831312e0b..59c710a37 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue @@ -145,45 +145,43 @@ const handleCreateArticle = event => { diff --git a/app/javascript/dashboard/components/IntersectionObserver.vue b/app/javascript/dashboard/components/IntersectionObserver.vue index c650a8c0e..36135bd44 100644 --- a/app/javascript/dashboard/components/IntersectionObserver.vue +++ b/app/javascript/dashboard/components/IntersectionObserver.vue @@ -1,5 +1,5 @@ - - diff --git a/app/javascript/dashboard/components/widgets/WootWriter/AudioRecorder.vue b/app/javascript/dashboard/components/widgets/WootWriter/AudioRecorder.vue index 924e72c53..b0428ecdb 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/AudioRecorder.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/AudioRecorder.vue @@ -1,6 +1,6 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/canned/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/canned/Index.vue index 5c1810b63..1bc1ae392 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/canned/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/canned/Index.vue @@ -4,7 +4,7 @@ import AddCanned from './AddCanned.vue'; import EditCanned from './EditCanned.vue'; import SettingsLayout from '../SettingsLayout.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; -import { computed, onMounted, ref, defineOptions } from 'vue'; +import { computed, onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useStoreGetters, useStore } from 'dashboard/composables/store'; import { picoSearch } from '@scmmishra/pico-search'; diff --git a/app/javascript/dashboard/routes/dashboard/settings/customRoles/component/CustomRolePaywall.vue b/app/javascript/dashboard/routes/dashboard/settings/customRoles/component/CustomRolePaywall.vue index 90636dbde..4a3387fa2 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/customRoles/component/CustomRolePaywall.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/customRoles/component/CustomRolePaywall.vue @@ -74,23 +74,25 @@ const tableHeaders = computed(() => {