diff --git a/.bundler-audit.yml b/.bundler-audit.yml index 908d97175..ffbfa18e0 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -2,8 +2,22 @@ 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+) - # Chatwoot defaults to Active Storage redirect-style URLs, and its recommended - # storage setup uses local/cloud storage with optional direct uploads to the - # storage provider rather than Rails proxy mode. Revisit if we enable - # rails_storage_proxy or other app-served Active Storage proxy routes. + # 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 + # Rails 7.1 has no patched release for this Active Storage direct-upload + # advisory. Chatwoot filters internal metadata keys locally. + - CVE-2026-33173 + - CVE-2026-33174 + # Rails 7.1 has no patched release for these Rails advisories. These are not + # reachable through Chatwoot's current usage patterns and should be removed + # once we upgrade to Rails 7.2.3.1+. + - CVE-2026-33168 + - CVE-2026-33169 + - CVE-2026-33170 + - CVE-2026-33176 + - CVE-2026-33195 + - CVE-2026-33202 diff --git a/.circleci/config.yml b/.circleci/config.yml index 59702c139..f764cb611 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,7 +144,7 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 18 + parallelism: 20 steps: - checkout - node/install: 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 8c0bb82a5..7533cf3cf 100644 --- a/Gemfile +++ b/Gemfile @@ -76,7 +76,7 @@ gem 'faraday_middleware-aws-sigv4' ##--- gems for server & infra configuration ---## gem 'dotenv-rails', '>= 3.0.0' gem 'foreman' -gem 'puma' +gem 'puma', '~> 7.2', '>= 7.2.1' gem 'vite_rails' # metrics on heroku gem 'barnes' @@ -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' @@ -195,10 +195,10 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.9.1' +gem 'ai-agents', '>= 0.10.0' # TODO: Move this gem as a dependency of ai-agents -gem 'ruby_llm', '>= 1.8.2' +gem 'ruby_llm', '>= 1.14.1' gem 'ruby_llm-schema' gem 'cld3', '~> 3.7' @@ -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 bd21b7a36..141afc122 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,8 +126,8 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.9.1) - ruby_llm (~> 1.9.1) + ai-agents (0.10.0) + ruby_llm (~> 1.14) annotaterb (4.20.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) @@ -136,6 +136,8 @@ GEM audited (5.4.1) activerecord (>= 5.0, < 7.7) activesupport (>= 5.0, < 7.7) + auth-sanitizer (0.2.1) + version_gem (~> 1.1, >= 1.1.10) aws-actionmailbox-ses (0.1.0) actionmailbox (>= 7.1.0) aws-sdk-s3 (~> 1, >= 1.123.0) @@ -168,7 +170,7 @@ GEM base64 (0.3.0) bcrypt (3.1.22) benchmark (0.4.1) - bigdecimal (3.2.2) + bigdecimal (3.3.1) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) @@ -184,6 +186,7 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) + cgi (0.5.1) childprocess (5.1.0) logger (~> 1.5) cld3 (3.7.0) @@ -196,6 +199,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) @@ -212,7 +218,7 @@ GEM logger msgpack datadog-ruby_core_source (3.4.1) - date (3.4.1) + date (3.5.1) debug (1.8.0) irb (>= 1.5.0) reline (>= 0.3.1) @@ -298,7 +304,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 @@ -307,9 +313,9 @@ GEM faraday-mashify (1.0.0) faraday (~> 2.0) hashie - faraday-multipart (1.0.4) - multipart-post (~> 2) - faraday-net_http (3.4.2) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (3.4.4) net-http (~> 0.5) faraday-net_http_persistent (2.1.0) faraday (~> 2.5) @@ -336,6 +342,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) @@ -431,7 +438,8 @@ GEM hana (1.3.7) hash_diff (1.1.1) hashdiff (1.1.0) - hashie (5.0.0) + hashie (5.1.0) + logger html2text (0.4.0) nokogiri (>= 1.0, < 2.0) http (5.1.1) @@ -466,7 +474,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.19.2) + json (2.19.8) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -487,7 +495,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) @@ -564,7 +572,7 @@ GEM ruby2_keywords msgpack (1.8.0) multi_json (1.15.0) - multi_xml (0.8.0) + multi_xml (0.9.1) bigdecimal (>= 3.1, < 5) multipart-post (2.4.1) mutex_m (0.3.0) @@ -574,7 +582,7 @@ GEM uri (>= 0.11.1) net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.20) + net-imap (0.4.24) date net-protocol net-pop (0.1.2) @@ -589,29 +597,36 @@ GEM sidekiq newrelic_rpm (9.6.0) base64 - nio4r (2.7.3) - nokogiri (1.19.1) + nio4r (2.7.5) + nokogiri (1.19.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.19.1-arm64-darwin) + nokogiri (1.19.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-darwin) + nokogiri (1.19.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-linux-gnu) + nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) - oauth (1.1.0) - oauth-tty (~> 1.0, >= 1.0.1) - snaky_hash (~> 2.0) - version_gem (~> 1.1) - oauth-tty (1.0.5) - version_gem (~> 1.1, >= 1.1.1) - oauth2 (2.0.9) - faraday (>= 0.17.3, < 3.0) - jwt (>= 1.0, < 3.0) + oauth (1.1.6) + auth-sanitizer (~> 0.2, >= 0.2.1) + base64 (~> 0.1) + cgi + oauth-tty (~> 1.0, >= 1.0.8) + snaky_hash (~> 2.0, >= 2.0.5) + version_gem (~> 1.1, >= 1.1.11) + oauth-tty (1.0.8) + auth-sanitizer (~> 0.1, >= 0.1.3) + cgi + version_gem (~> 1.1, >= 1.1.9) + oauth2 (2.0.22) + auth-sanitizer (~> 0.2, >= 0.2.1) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) multi_xml (~> 0.5) rack (>= 1.2, < 4) - snaky_hash (~> 2.0) - version_gem (~> 1.1) + snaky_hash (~> 2.0, >= 2.0.5) + version_gem (~> 1.1, >= 1.1.11) oj (3.16.10) bigdecimal (>= 3.0) ostruct (>= 0.2) @@ -678,7 +693,7 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (7.0.5) - puma (6.4.3) + puma (7.2.1) nio4r (~> 2.0) pundit (2.3.0) activesupport (>= 3.0.0) @@ -835,17 +850,17 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_llm (1.9.2) + ruby_llm (1.15.0) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) faraday-multipart (>= 1) faraday-net_http (>= 1) faraday-retry (>= 1) - marcel (~> 1.0) - ruby_llm-schema (~> 0.2.1) + marcel (~> 1) + ruby_llm-schema (~> 0) zeitwerk (~> 2) - ruby_llm-schema (0.2.5) + ruby_llm-schema (0.3.0) ruby_parser (3.20.0) sexp_processor (~> 4.16) sass (3.7.4) @@ -902,10 +917,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) @@ -930,9 +946,9 @@ GEM gli hashie logger - snaky_hash (2.0.1) - hashie - version_gem (~> 1.1, >= 1.1.1) + snaky_hash (2.0.5) + hashie (>= 0.1.0, < 6) + version_gem (>= 1.1.8, < 3) sorbet-runtime (0.5.11934) spring (4.1.1) spring-watcher-listen (2.1.0) @@ -961,7 +977,7 @@ GEM time_diff (0.3.0) activesupport i18n - timeout (0.4.3) + timeout (0.6.1) trailblazer-option (0.1.2) twilio-ruby (7.6.0) faraday (>= 0.9, < 3.0) @@ -979,6 +995,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) @@ -989,12 +1006,14 @@ GEM valid_email2 (5.2.6) activemodel (>= 3.2) mail (~> 2.5) - version_gem (1.1.4) - vite_rails (3.0.17) - railties (>= 5.1, < 8) + version_gem (1.1.11) + vite_rails (3.10.0) + railties (>= 5.1, < 9) vite_ruby (~> 3.0, >= 3.2.2) - vite_ruby (3.8.0) + vite_ruby (3.10.2) dry-cli (>= 0.7, < 2) + logger (~> 1.6) + mutex_m rack-proxy (~> 0.6, >= 0.6.1) zeitwerk (~> 2.2) warden (1.2.9) @@ -1019,7 +1038,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.7.4) + zeitwerk (2.7.5) PLATFORMS arm64-darwin-20 @@ -1039,7 +1058,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.9.1) + ai-agents (>= 0.10.0) annotaterb attr_extras audited (~> 5.4, >= 5.4.1) @@ -1074,6 +1093,7 @@ DEPENDENCIES faker faraday_middleware-aws-sigv4 fcm + firecrawl-sdk (~> 1.0) flag_shih_tzu foreman gemoji @@ -1095,7 +1115,7 @@ DEPENDENCIES json_schemer judoscale-rails judoscale-sidekiq - jwt + jwt (~> 2.10, >= 2.10.3) kaminari koala letter_opener @@ -1123,7 +1143,7 @@ DEPENDENCIES pgvector procore-sift pry-rails - puma + puma (~> 7.2, >= 7.2.1) pundit rack-attack (>= 6.7.0) rack-cors (= 2.0.0) @@ -1144,7 +1164,7 @@ DEPENDENCIES rubocop-rails rubocop-rspec ruby-openai - ruby_llm (>= 1.8.2) + ruby_llm (>= 1.14.1) ruby_llm-schema scout_apm scss_lint @@ -1155,8 +1175,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 813b83b65..0fb7a35b6 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.13.0 +4.14.2 diff --git a/app/builders/contact_inbox_with_contact_builder.rb b/app/builders/contact_inbox_with_contact_builder.rb index 2c0e6087e..2ba9179ed 100644 --- a/app/builders/contact_inbox_with_contact_builder.rb +++ b/app/builders/contact_inbox_with_contact_builder.rb @@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder def create_contact account.contacts.create!( - name: contact_attributes[:name] || ::Haikunator.haikunate(1000), + name: contact_name, phone_number: contact_attributes[:phone_number], email: contact_attributes[:email], identifier: contact_attributes[:identifier], @@ -59,6 +59,11 @@ class ContactInboxWithContactBuilder ) end + def contact_name + name = contact_attributes[:name] || ::Haikunator.haikunate(1000) + name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '') + end + def find_contact contact = find_contact_by_identifier(contact_attributes[:identifier]) contact ||= find_contact_by_email(contact_attributes[:email]) diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 1f59deadb..24b6d9e70 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -92,10 +92,18 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder def fallback_params(attachment) { fallback_title: attachment['title'], - external_url: attachment['url'] + external_url: attachment['url'] || attachment.dig('payload', 'url') } end + # Facebook shared posts point to page URLs, not downloadable media URLs. + # Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling. + def normalize_file_type(type) + return :fallback if type.to_sym == :share + + super + end + def conversation_params { account_id: @inbox.account_id, diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 7df72e14a..297e77fcc 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -13,6 +13,7 @@ class Messages::MessageBuilder @account = conversation.account @message_type = params[:message_type] || 'outgoing' @attachments = params[:attachments] + @is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message]) @automation_rule = content_attributes&.dig(:automation_rule_id) return unless params.instance_of?(ActionController::Parameters) @@ -56,16 +57,25 @@ class Messages::MessageBuilder file: uploaded_attachment ) - attachment.file_type = if uploaded_attachment.is_a?(String) - file_type_by_signed_id( - uploaded_attachment - ) - else - file_type(uploaded_attachment&.content_type) - end + attachment.file_type = attachment_file_type(uploaded_attachment) + tag_voice_message(attachment) end end + def attachment_file_type(uploaded_attachment) + if uploaded_attachment.is_a?(String) + file_type_by_signed_id(uploaded_attachment) + else + file_type(uploaded_attachment&.content_type) + end + end + + def tag_voice_message(attachment) + return unless @is_voice_message && attachment.file_type == 'audio' + + attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true) + end + def process_emails return unless @conversation.inbox&.inbox_type == 'Email' diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index be1f98b43..ecd6f06ea 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -28,6 +28,10 @@ class Messages::Messenger::MessageBuilder filename: attachment_file.original_filename, content_type: attachment_file.content_type ) + # The Attachment row is saved before the blob is attached, so the + # after_create_commit broadcast bails on `file.attached?`. Re-fire here + # for audio so the bubble updates without waiting on transcription. + attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio end def attachment_params(attachment) diff --git a/app/builders/notification_builder.rb b/app/builders/notification_builder.rb index d5461eb4e..e09e44d50 100644 --- a/app/builders/notification_builder.rb +++ b/app/builders/notification_builder.rb @@ -27,6 +27,8 @@ class NotificationBuilder return if notification_type == 'conversation_creation' && !user_subscribed_to_notification? # skip notifications for blocked conversations except for user mentions return if primary_actor.contact.blocked? && notification_type != 'conversation_mention' + # respect conversation access (inbox/team membership and custom-role permissions) + return unless user_can_access_conversation? user.notifications.create!( notification_type: notification_type, @@ -36,4 +38,17 @@ class NotificationBuilder secondary_actor: secondary_actor || current_user ) end + + def user_can_access_conversation? + conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation) + return true if conversation.blank? + + account_user = AccountUser.find_by(account_id: account.id, user_id: user.id) + return false if account_user.blank? + + ConversationPolicy.new( + { user: user, account: account, account_user: account_user }, + conversation + ).show? + end end diff --git a/app/builders/v2/reports/bot_metrics_builder.rb b/app/builders/v2/reports/bot_metrics_builder.rb index c46daf978..f151e1a66 100644 --- a/app/builders/v2/reports/bot_metrics_builder.rb +++ b/app/builders/v2/reports/bot_metrics_builder.rb @@ -31,13 +31,24 @@ class V2::Reports::BotMetricsBuilder end def bot_resolutions_count - account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved, - created_at: range).distinct.count + # Exclude conversations that also had a handoff in the same range — handoff wins + account.reporting_events.joins(:conversation).select(:conversation_id) + .where(account_id: account.id, name: :conversation_bot_resolved, created_at: range) + .where.not(conversation_id: bot_handoff_conversation_ids_subquery) + .distinct.count end def bot_handoffs_count - account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff, - created_at: range).distinct.count + account.reporting_events.joins(:conversation).select(:conversation_id) + .where(account_id: account.id, name: :conversation_bot_handoff, created_at: range) + .distinct.count + end + + def bot_handoff_conversation_ids_subquery + account.reporting_events + .where(name: :conversation_bot_handoff, created_at: range) + .where.not(conversation_id: nil) + .select(:conversation_id) end def bot_resolution_rate 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/contacts/attachments_controller.rb b/app/controllers/api/v1/accounts/contacts/attachments_controller.rb new file mode 100644 index 000000000..761f00130 --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/attachments_controller.rb @@ -0,0 +1,18 @@ +class Api::V1::Accounts::Contacts::AttachmentsController < Api::V1::Accounts::Contacts::BaseController + RESULTS_PER_PAGE = 100 + + def index + conversations = Conversations::PermissionFilterService.new( + Current.account.conversations.where(contact_id: @contact.id), + Current.user, + Current.account + ).perform + + @attachments = Attachment.where(message_id: Message.where(conversation_id: conversations).select(:id)) + .includes({ file_attachment: :blob }, message: [:conversation, :inbox, { sender: { avatar_attachment: :blob } }]) + .order(created_at: :desc) + .page(params[:page]) + .per(RESULTS_PER_PAGE) + @attachments_count = @attachments.total_count + end +end 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 6159f804d..2856c7817 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def attachments @attachments_count = @conversation.attachments.count @attachments = @conversation.attachments - .includes(:message) + .includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }]) .order(created_at: :desc) .page(attachment_params[:page]) .per(ATTACHMENT_RESULTS_PER_PAGE) @@ -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/custom_attribute_definitions_controller.rb b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb index 69df99e14..7bd9ae0c3 100644 --- a/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb +++ b/app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb @@ -1,6 +1,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController before_action :fetch_custom_attributes_definitions, except: [:create] before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy] + before_action :check_authorization DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze def index; end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index c7d3e2737..757af9b62 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -9,7 +9,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController include Api::V1::Accounts::Concerns::WhatsappHealthManagement def index - @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) + @inboxes = policy_scope(Current.account.inboxes) + .includes(:channel, :portal, :working_hours, { avatar_attachment: :blob }) + .order_by_name end def show; end @@ -85,7 +87,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def fetch_agent_bot - @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] + @agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot] end def create_channel diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb index 053c29731..116a31e85 100644 --- a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts enable_fb_login: '0', force_authentication: '1', response_type: 'code', - state: generate_instagram_token(Current.account.id) + state: generate_instagram_token(Current.account.id, params[:return_to]) } ) if redirect_url diff --git a/app/controllers/api/v1/accounts/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb index 54455943b..6889d30a4 100644 --- a/app/controllers/api/v1/accounts/labels_controller.rb +++ b/app/controllers/api/v1/accounts/labels_controller.rb @@ -18,7 +18,16 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController end def destroy + label_title = @label.title + account_id = Current.account.id + label_deleted_at = Time.current + @label.destroy! + Labels::RemoveAssociationsJob.perform_later( + label_title: label_title, + account_id: account_id, + label_deleted_at: label_deleted_at + ) head :ok end diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb index a300b5f59..c65a3031d 100644 --- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts { redirect_uri: "#{base_url}/microsoft/callback", scope: scope, - state: state, - prompt: 'consent' + state: state } ) if redirect_url diff --git a/app/controllers/api/v1/accounts/notifications_controller.rb b/app/controllers/api/v1/accounts/notifications_controller.rb index 52035ce64..bb5c57ca6 100644 --- a/app/controllers/api/v1/accounts/notifications_controller.rb +++ b/app/controllers/api/v1/accounts/notifications_controller.rb @@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro def destroy_all if params[:type] == 'read' - ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read) + ::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read) else - ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all) + ::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all) end head :ok end @@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro end def fetch_notification - @notification = current_user.notifications.find(params[:id]) + @notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id]) end def set_current_page diff --git a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb index feb218b59..7fbca86da 100644 --- a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb +++ b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb @@ -8,7 +8,15 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC end def state - Current.account.to_sgid(expires_in: 15.minutes).to_s + # The sgid purpose doubles as a return hint: onboarding tags it so the callback + # can route the user back to inbox setup. The purpose is part of the signed + # payload (tamper-proof), and a non-onboarding request keeps the default + # purpose, leaving callers like Notion byte-identical. + Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s + end + + def state_purpose + params[:return_to] == 'onboarding' ? 'onboarding' : 'default' end def base_url diff --git a/app/controllers/api/v1/accounts/onboardings_controller.rb b/app/controllers/api/v1/accounts/onboardings_controller.rb new file mode 100644 index 000000000..181e4965e --- /dev/null +++ b/app/controllers/api/v1/accounts/onboardings_controller.rb @@ -0,0 +1,51 @@ +class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController + before_action :check_admin_authorization? + + def update + @account = Current.account + finalize = finalizing_account_details? + + @account.assign_attributes(account_params) + @account.custom_attributes.merge!(custom_attributes_params) + @account.custom_attributes.delete('onboarding_step') if finalize + @account.save! + + # TODO: re-enable when the help center generation UI is ready to surface progress + # Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present? + + render 'api/v1/accounts/update', format: :json + end + + def help_center_generation + render json: help_center_generation_status + end + + private + + def finalizing_account_details? + @account.custom_attributes['onboarding_step'] == 'account_details' + end + + def website + custom_attributes_params[:website] + end + + def account_params + params.permit(:name, :locale) + end + + def custom_attributes_params + params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website) + end + + def help_center_generation_status + { + generation_id: nil, + state: nil, + articles_count: 0, + categories_count: 0 + } + end +end + +Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController') diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index ade83d8ec..c74c0ecfc 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController @portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params)) @portal.custom_domain = parsed_custom_domain @portal.save! - process_attached_logo + process_attached_logo if params[:blob_id].present? end def update @@ -78,16 +78,23 @@ 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] }, + { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] } ) end + def locale_translation_keys + params.dig(:portal, :config, :locale_translations)&.keys || [] + end + def live_chat_widget_params permitted_params = params.permit(:inbox_id) return {} unless permitted_params.key?(:inbox_id) return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank? - inbox = Inbox.find(permitted_params[:inbox_id]) + inbox = Current.account.inboxes.find(permitted_params[:inbox_id]) return {} unless inbox.web_widget? { channel_web_widget_id: inbox.channel.id } @@ -98,6 +105,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController end def parsed_custom_domain + return @portal.custom_domain if @portal.custom_domain.blank? + domain = URI.parse(@portal.custom_domain) domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain end diff --git a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb index 7c7320393..64bf38775 100644 --- a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb @@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O def create redirect_url = Tiktok::AuthClient.authorize_url( - state: generate_tiktok_token(Current.account.id) + state: generate_tiktok_token(Current.account.id, params[:return_to]) ) if redirect_url 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/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index dddf3dd09..fb991949a 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -58,7 +58,6 @@ class Api::V1::AccountsController < Api::BaseController @account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email)) @account.custom_attributes.merge!(custom_attributes_params) @account.settings.merge!(settings_params) - @account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details' @account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update' @account.save! end @@ -111,7 +110,7 @@ class Api::V1::AccountsController < Api::BaseController end def custom_attributes_params - params.permit(:industry, :company_size, :timezone, :referral_source, :user_role) + params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website) end def settings_params diff --git a/app/controllers/api/v1/notification_subscriptions_controller.rb b/app/controllers/api/v1/notification_subscriptions_controller.rb index 1a797a74d..bb20c64ae 100644 --- a/app/controllers/api/v1/notification_subscriptions_controller.rb +++ b/app/controllers/api/v1/notification_subscriptions_controller.rb @@ -8,7 +8,8 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController end def destroy - notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first + notification_subscription = current_user.notification_subscriptions + .where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first notification_subscription.destroy! if notification_subscription.present? head :ok end diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index 83b3dc8b1..8b1e8ef48 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -83,6 +83,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController end def set_message - @message = @web_widget.inbox.messages.find(permitted_params[:id]) + # `conversation.messages.find` would be simpler, but `conversation` is `conversations.last`, + # which means a visitor with more than one open thread could not edit a message in any + # but their most recent one. Scoping across all of the visitor's conversations keeps the + # happy path correct for that future multi-conversation widget flow. + @message = Message.where(conversation_id: conversations.select(:id)).find(permitted_params[:id]) end end diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index b7fc14e74..fb52a8eeb 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -1,8 +1,9 @@ module AccessTokenAuthHelper BOT_ACCESSIBLE_ENDPOINTS = { - 'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes], + 'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes], 'api/v1/accounts/conversations/messages' => ['create'], - 'api/v1/accounts/conversations/assignments' => ['create'] + 'api/v1/accounts/conversations/assignments' => ['create'], + 'api/v1/accounts/conversations/labels' => %w[index create] }.freeze def ensure_access_token diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index 974fb05e4..bd7bb9b44 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -25,6 +25,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController private + def render_create_error_not_confirmed + render_error( + :unauthorized, + I18n.t('devise_token_auth.sessions.not_confirmed', email: @resource.email), + error_code: 'user_not_confirmed' + ) + end + def find_user_for_authentication return nil unless params[:email].present? && params[:password].present? diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb index 4dc8ece1c..cd317363c 100644 --- a/app/controllers/instagram/callbacks_controller.rb +++ b/app/controllers/instagram/callbacks_controller.rb @@ -28,6 +28,8 @@ class Instagram::CallbacksController < ApplicationController @long_lived_token_response = exchange_for_long_lived_token(@response.token) inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding' + if already_exists redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) else @@ -149,6 +151,10 @@ class Instagram::CallbacksController < ApplicationController verify_instagram_token(params[:state]) end + def return_to + instagram_token_return_to(params[:state]) + end + def oauth_code params[:code] end diff --git a/app/controllers/microsoft/callbacks_controller.rb b/app/controllers/microsoft/callbacks_controller.rb index 2f07505fc..045789f75 100644 --- a/app/controllers/microsoft/callbacks_controller.rb +++ b/app/controllers/microsoft/callbacks_controller.rb @@ -14,4 +14,11 @@ class Microsoft::CallbacksController < OauthCallbackController def imap_address 'outlook.office365.com' end + + # Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field; + # it must match the token's UPN. `preferred_username` is the documented v2.0 claim; + # `upn` is the v1.0 fallback. + def imap_login_identity + users_data['preferred_username'] || users_data['upn'] || super + end end diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index be0fa5008..4a3d049a6 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -16,6 +16,8 @@ class OauthCallbackController < ApplicationController def handle_response inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding' + if already_exists redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) else @@ -44,7 +46,7 @@ class OauthCallbackController < ApplicationController def update_channel(channel_email) channel_email.update!({ - imap_login: users_data['email'], imap_address: imap_address, + imap_login: imap_login_identity, imap_address: imap_address, imap_port: '993', imap_enabled: true, provider: provider_name, provider_config: { @@ -55,6 +57,13 @@ class OauthCallbackController < ApplicationController }) end + # Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the + # id_token's email claim; providers override when their server requires a different + # claim (e.g. Microsoft SMTP requires UPN). + def imap_login_identity + users_data['email'] + end + def provider_name raise NotImplementedError end @@ -81,10 +90,19 @@ class OauthCallbackController < ApplicationController decoded_token[0] end + # The sgid purpose carries the onboarding return hint (see + # OauthAuthorizationController#state). Try the onboarding purpose first — a match + # both resolves the account and records the return target — then fall back to the + # default purpose used by every other caller. def account_from_signed_id raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? - account = GlobalID::Locator.locate_signed(params[:state]) + if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding')) + @return_to = 'onboarding' + else + account = GlobalID::Locator.locate_signed(params[:state]) + end + raise 'Invalid or expired state' if account.nil? account @@ -94,6 +112,11 @@ class OauthCallbackController < ApplicationController @account ||= account_from_signed_id end + def return_to + account # resolving the sgid records which purpose matched + @return_to + end + # Fallback name, for when name field is missing from users_data def fallback_name users_data['email'].split('@').first.parameterize.titleize diff --git a/app/controllers/public/api/v1/inboxes_controller.rb b/app/controllers/public/api/v1/inboxes_controller.rb index 65fad57b1..5679a4a3e 100644 --- a/app/controllers/public/api/v1/inboxes_controller.rb +++ b/app/controllers/public/api/v1/inboxes_controller.rb @@ -24,6 +24,10 @@ class Public::Api::V1::InboxesController < PublicController def set_conversation return if params[:conversation_id].blank? - @conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id]) + @conversation = if @contact_inbox.hmac_verified? + @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id]) + else + @contact_inbox.conversations.find_by!(display_id: params[:conversation_id]) + end 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/search_controller.rb b/app/controllers/public/api/v1/portals/search_controller.rb new file mode 100644 index 000000000..104741773 --- /dev/null +++ b/app/controllers/public/api/v1/portals/search_controller.rb @@ -0,0 +1,31 @@ +class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController + before_action :ensure_custom_domain_request, only: [:index] + before_action :portal + before_action :set_portal_layout + before_action :set_view_variant + before_action :ensure_portal_feature_enabled + layout 'portal' + + def index + @query = params[:query].to_s.strip + @articles = @portal.articles.published.includes(:category).where(locale: params[:locale]) + + search_articles + + @articles = @articles.page(params[:page]).per(10) + end + + private + + def search_articles + @articles = @query.present? ? @articles.search(search_params) : @articles.none + end + + def search_params + params.permit(:query, :locale, :sort, :status, :page).tap do |permitted| + permitted[:query] = @query + end + end +end + +Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController') diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index a187ca8a8..57db11aec 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -2,11 +2,14 @@ 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 - @og_image_url = helpers.set_og_image_url('', @portal.header_text) + @og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale)) end def sitemap @@ -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/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 67d58aef1..86d1b70ef 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController if errors.any? redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ') else - redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully" + redirect_to super_admin_settings_path, flash: success_flash end end @@ -58,6 +58,21 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY] ) end + + def success_notice + message = "#{@config.titleize} settings updated successfully" + return message unless restart_required_config_saved? + + "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." + end + + def success_flash + restart_required_config_saved? ? { success: success_notice } : { notice: success_notice } + end + + def restart_required_config_saved? + params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS) + end end SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController') diff --git a/app/controllers/super_admin/installation_configs_controller.rb b/app/controllers/super_admin/installation_configs_controller.rb index b1f15b518..e888b91e7 100644 --- a/app/controllers/super_admin/installation_configs_controller.rb +++ b/app/controllers/super_admin/installation_configs_controller.rb @@ -25,6 +25,29 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl resource_class.editable end + def create + resource = new_resource(resource_params) + authorize_resource(resource) + + if resource.save + redirect_to after_resource_created_path(resource), flash: success_flash(resource) + else + render :new, locals: { + page: Administrate::Page::Form.new(dashboard, resource) + }, status: :unprocessable_entity + end + end + + def update + if requested_resource.update(resource_params) + redirect_to after_resource_updated_path(requested_resource), flash: success_flash(requested_resource) + else + render :edit, locals: { + page: Administrate::Page::Form.new(dashboard, requested_resource) + }, status: :unprocessable_entity + end + end + # Override `resource_params` if you want to transform the submitted # data before it's persisted. For example, the following would turn all # empty values into nil values. It uses other APIs such as `resource_class` @@ -42,6 +65,20 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl .transform_values { |value| value == '' ? nil : value }.merge(locked: false) end + private + + def success_flash(resource) + message = translate_with_resource('update.success') + message = translate_with_resource('create.success') if action_name == 'create' + return { notice: message } unless restart_required_config?(resource) + + { success: "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." } + end + + def restart_required_config?(resource) + resource.name.in?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS) + end + # See https://administrate-prototype.herokuapp.com/customizing_controller_actions # for more information end diff --git a/app/controllers/swagger_controller.rb b/app/controllers/swagger_controller.rb index af5a4b039..680bf92fc 100644 --- a/app/controllers/swagger_controller.rb +++ b/app/controllers/swagger_controller.rb @@ -1,7 +1,12 @@ class SwaggerController < ApplicationController def respond if Rails.env.development? || Rails.env.test? - render inline: Rails.root.join('swagger', derived_path).read + swagger_root = Rails.root.join('swagger') + file_path = swagger_root.join(derived_path).cleanpath + + return head :not_found unless file_path.to_s.start_with?("#{swagger_root}/") && file_path.file? + + render inline: file_path.read else head :not_found end @@ -11,8 +16,8 @@ class SwaggerController < ApplicationController def derived_path params[:path] ||= 'index.html' - path = Rack::Utils.clean_path_info(params[:path]) - path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s) + path = Rack::Utils.clean_path_info(params[:path]).delete_prefix('/') + path << ".#{Rack::Utils.clean_path_info(params[:format]).delete_prefix('/')}" unless path.ends_with?(params[:format].to_s) path end end diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb index e484905c3..20c0ee9c0 100644 --- a/app/controllers/tiktok/callbacks_controller.rb +++ b/app/controllers/tiktok/callbacks_controller.rb @@ -20,6 +20,8 @@ class Tiktok::CallbacksController < ApplicationController def process_successful_authorization inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding' + if already_exists redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) else @@ -127,6 +129,10 @@ class Tiktok::CallbacksController < ApplicationController @account_id ||= verify_tiktok_token(params[:state]) end + def return_to + tiktok_token_return_to(params[:state]) + end + def account @account ||= Account.find(account_id) 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/drops/contact_drop.rb b/app/drops/contact_drop.rb index 1d450adb7..16240ec06 100644 --- a/app/drops/contact_drop.rb +++ b/app/drops/contact_drop.rb @@ -12,7 +12,7 @@ class ContactDrop < BaseDrop end def first_name - @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1 + @obj.try(:name).try(:split).try(:first).try(:capitalize) end def last_name diff --git a/app/drops/user_drop.rb b/app/drops/user_drop.rb index cf6f1b6a1..83d5e2347 100644 --- a/app/drops/user_drop.rb +++ b/app/drops/user_drop.rb @@ -12,7 +12,7 @@ class UserDrop < BaseDrop end def first_name - @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1 + @obj.try(:name).try(:split).try(:first).try(:capitalize) end def last_name diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index fa437327d..1c27d8260 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -40,7 +40,7 @@ class ConversationFinder def perform set_up - mine_count, unassigned_count, all_count, = set_count_for_all_conversations + mine_count, unassigned_count, all_count = set_count_for_all_conversations assigned_count = all_count - unassigned_count filter_by_assignee_type @@ -184,6 +184,17 @@ class ConversationFinder end def set_count_for_all_conversations + return legacy_count_for_all_conversations if @conversations.limit_value || @conversations.offset_value || @conversations.eager_loading? + + counts = @conversations.unscope(:order).pick( + Arel.sql("COUNT(*) FILTER (WHERE assignee_id = #{current_user.id})"), + Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL)'), + Arel.sql('COUNT(*)') + ) + counts || [0, 0, 0] + end + + def legacy_count_for_all_conversations [ @conversations.assigned_to(current_user).count, @conversations.unassigned.count, diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 3d6b559c8..8a10fa99c 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -17,15 +17,12 @@ module Api::V1::InboxesHelper def validate_imap(channel_data) return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled] - Mail.defaults do - retriever_method :imap, { address: channel_data[:imap_address], - port: channel_data[:imap_port], - user_name: channel_data[:imap_login], - password: channel_data[:imap_password], - enable_ssl: channel_data[:imap_enable_ssl] } - end + # Validate the user-selected auth mechanism before opening the connection. + authentication = Imap::Authentication.validate_user_configurable!(channel_data[:imap_authentication]) - check_imap_connection(channel_data) + # Use the same auth adapter as the fetch service so LOGIN uses the IMAP LOGIN command, + # not SASL AUTH=LOGIN. + check_imap_connection(channel_data, authentication) end def validate_smtp(channel_data) @@ -37,8 +34,8 @@ module Api::V1::InboxesHelper check_smtp_connection(channel_data, smtp) end - def check_imap_connection(channel_data) - Mail.connection {} # rubocop:disable:block + def check_imap_connection(channel_data, authentication) + imap = open_imap_connection(channel_data, authentication) rescue SocketError => e raise StandardError, I18n.t('errors.inboxes.imap.socket_error') rescue Net::IMAP::NoResponseError => e @@ -53,9 +50,20 @@ module Api::V1::InboxesHelper rescue StandardError => e raise StandardError, e.message ensure + imap.disconnect if imap.present? && !imap.disconnected? Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present? end + def open_imap_connection(channel_data, authentication) + imap = build_imap_connection(channel_data) + Imap::Authentication.authenticate!(imap, authentication, channel_data[:imap_login], channel_data[:imap_password]) + imap + end + + def build_imap_connection(channel_data) + Net::IMAP.new(channel_data[:imap_address], port: channel_data[:imap_port], ssl: channel_data[:imap_enable_ssl]) + end + def check_smtp_connection(channel_data, smtp) smtp.open_timeout = 10 smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password], 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/instagram/integration_helper.rb b/app/helpers/instagram/integration_helper.rb index 8ba57bf95..2f91eb6b0 100644 --- a/app/helpers/instagram/integration_helper.rb +++ b/app/helpers/instagram/integration_helper.rb @@ -4,21 +4,21 @@ module Instagram::IntegrationHelper # Generates a signed JWT token for Instagram integration # # @param account_id [Integer] The account ID to encode in the token + # @param return_to [String, nil] Optional onboarding return hint # @return [String, nil] The encoded JWT token or nil if client secret is missing - def generate_instagram_token(account_id) + def generate_instagram_token(account_id, return_to = nil) return if client_secret.blank? - JWT.encode(token_payload(account_id), client_secret, 'HS256') + JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256') rescue StandardError => e Rails.logger.error("Failed to generate Instagram token: #{e.message}") nil end - def token_payload(account_id) - { - sub: account_id, - iat: Time.current.to_i - } + def token_payload(account_id, return_to = nil) + payload = { sub: account_id, iat: Time.current.to_i } + payload[:return_to] = return_to if return_to.present? + payload end # Verifies and decodes a Instagram JWT token @@ -28,7 +28,14 @@ module Instagram::IntegrationHelper def verify_instagram_token(token) return if token.blank? || client_secret.blank? - decode_token(token, client_secret) + decode_token(token, client_secret)&.dig('sub') + end + + # Reads the onboarding return hint from a Instagram JWT token, if present. + def instagram_token_return_to(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret)&.dig('return_to') end private @@ -41,7 +48,7 @@ module Instagram::IntegrationHelper JWT.decode(token, secret, true, { algorithm: 'HS256', verify_expiration: true - }).first['sub'] + }).first rescue StandardError => e Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}") nil diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 15de0fbd7..0c993ec59 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -45,9 +45,16 @@ module PortalHelper theme.present? && theme != 'system' ? "?theme=#{theme}" : '' end + def portal_query_string(theme, is_plain_layout_enabled) + query_params = {} + query_params[:theme] = theme if theme.present? && theme != 'system' + query_params[:show_plain_layout] = true if is_plain_layout_enabled + query_params.present? ? "?#{query_params.to_query}" : '' + end + def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled) if is_plain_layout_enabled - "/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}" + "/hc/#{portal_slug}/#{portal_locale}#{portal_query_string(theme, is_plain_layout_enabled)}" else "/hc/#{portal_slug}/#{portal_locale}" end @@ -61,7 +68,7 @@ module PortalHelper is_plain_layout_enabled = params[:is_plain_layout_enabled] if is_plain_layout_enabled - "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}" + "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{portal_query_string(theme, is_plain_layout_enabled)}" else "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}" end @@ -69,7 +76,7 @@ module PortalHelper def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled) if is_plain_layout_enabled - "/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}" + "/hc/#{portal_slug}/articles/#{article_slug}#{portal_query_string(theme, is_plain_layout_enabled)}" else "/hc/#{portal_slug}/articles/#{article_slug}" end @@ -96,4 +103,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/helpers/report_helper.rb b/app/helpers/report_helper.rb index 09a84b110..d5b773c87 100644 --- a/app/helpers/report_helper.rb +++ b/app/helpers/report_helper.rb @@ -53,13 +53,12 @@ module ReportHelper end def resolutions - scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, - created_at: range) + scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range) end def bot_resolutions - scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, - created_at: range) + scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range) + .where.not(conversation_id: bot_handoff_conversation_ids_subquery) end def bot_handoffs @@ -67,6 +66,10 @@ module ReportHelper created_at: range).distinct end + def bot_handoff_conversation_ids_subquery + bot_handoffs + end + def avg_first_response_time grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id)) return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index f21a97f78..6489d0194 100644 --- a/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -34,6 +34,12 @@ disable_branding: enabled: <%= (ChatwootHub.pricing_plan != 'community') %> icon: 'icon-sailbot-fill' enterprise: true +voice_calls: + name: 'Voice Calls' + description: 'Enable voice calling capabilities for your agents and customers.' + enabled: <%= (ChatwootHub.pricing_plan != 'community') %> + icon: 'icon-voice-line' + enterprise: true # ------- Product Features ------- # help_center: diff --git a/app/helpers/tiktok/integration_helper.rb b/app/helpers/tiktok/integration_helper.rb index b2de4a092..7bc8bc4ab 100644 --- a/app/helpers/tiktok/integration_helper.rb +++ b/app/helpers/tiktok/integration_helper.rb @@ -2,11 +2,12 @@ module Tiktok::IntegrationHelper # Generates a signed JWT token for Tiktok integration # # @param account_id [Integer] The account ID to encode in the token + # @param return_to [String, nil] Optional onboarding return hint # @return [String, nil] The encoded JWT token or nil if client secret is missing - def generate_tiktok_token(account_id) + def generate_tiktok_token(account_id, return_to = nil) return if client_secret.blank? - JWT.encode(token_payload(account_id), client_secret, 'HS256') + JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256') rescue StandardError => e Rails.logger.error("Failed to generate TikTok token: #{e.message}") nil @@ -19,7 +20,14 @@ module Tiktok::IntegrationHelper def verify_tiktok_token(token) return if token.blank? || client_secret.blank? - decode_token(token, client_secret) + decode_token(token, client_secret)&.dig('sub') + end + + # Reads the onboarding return hint from a Tiktok JWT token, if present. + def tiktok_token_return_to(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret)&.dig('return_to') end private @@ -28,18 +36,17 @@ module Tiktok::IntegrationHelper @client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil) end - def token_payload(account_id) - { - sub: account_id, - iat: Time.current.to_i - } + def token_payload(account_id, return_to = nil) + payload = { sub: account_id, iat: Time.current.to_i } + payload[:return_to] = return_to if return_to.present? + payload end def decode_token(token, secret) JWT.decode(token, secret, true, { algorithm: 'HS256', verify_expiration: true - }).first['sub'] + }).first rescue StandardError => e Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}") nil diff --git a/app/javascript/dashboard/api/captain/document.js b/app/javascript/dashboard/api/captain/document.js index dc22b0c32..e23a8c460 100644 --- a/app/javascript/dashboard/api/captain/document.js +++ b/app/javascript/dashboard/api/captain/document.js @@ -6,15 +6,22 @@ class CaptainDocument extends ApiClient { super('captain/documents', { accountScoped: true }); } - get({ page = 1, searchKey, assistantId } = {}) { + get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) { return axios.get(this.url, { params: { page, - searchKey, + search_key: searchKey, assistant_id: assistantId, + filter, + source, + sort, }, }); } + + sync(id) { + return axios.post(`${this.url}/${id}/sync`); + } } export default new CaptainDocument(); diff --git a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js index 13f61a16c..14dd56ec9 100644 --- a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js +++ b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js @@ -48,6 +48,12 @@ class TwilioVoiceClient extends EventTarget { return !!this.activeConnection; } + setMuted(shouldMute) { + if (!this.activeConnection) return false; + this.activeConnection.mute(shouldMute); + return shouldMute; + } + endClientCall() { if (this.activeConnection) { this.activeConnection.disconnect(); 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/companies.js b/app/javascript/dashboard/api/companies.js index 090b530c4..a45b21d68 100644 --- a/app/javascript/dashboard/api/companies.js +++ b/app/javascript/dashboard/api/companies.js @@ -1,21 +1,12 @@ /* global axios */ import ApiClient from './ApiClient'; -export const buildCompanyParams = (page, sort) => { - let params = `page=${page}`; - if (sort) { - params = `${params}&sort=${sort}`; - } - return params; -}; - -export const buildSearchParams = (query, page, sort) => { - let params = `q=${encodeURIComponent(query)}&page=${page}`; - if (sort) { - params = `${params}&sort=${sort}`; - } - return params; -}; +const buildParams = params => + new URLSearchParams( + Object.entries(params).filter( + ([key, value]) => value !== undefined && (value !== '' || key === 'q') + ) + ).toString(); class CompanyAPI extends ApiClient { constructor() { @@ -24,14 +15,49 @@ class CompanyAPI extends ApiClient { get(params = {}) { const { page = 1, sort = 'name' } = params; - const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`; + const requestURL = `${this.url}?${buildParams({ page, sort })}`; return axios.get(requestURL); } search(query = '', page = 1, sort = 'name') { - const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`; + const requestURL = `${this.url}/search?${buildParams({ q: query, page, sort })}`; return axios.get(requestURL); } + + listContacts(id, page = 1) { + return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`); + } + + listNotes(id) { + return axios.get(`${this.url}/${id}/notes`); + } + + listConversations(id) { + return axios.get(`${this.url}/${id}/conversations`); + } + + searchContacts(id, query = '', page = 1) { + const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`; + return axios.get(requestURL); + } + + createContact(id, payload) { + return axios.post(`${this.url}/${id}/contacts`, payload); + } + + removeContact(id, contactId) { + return axios.delete(`${this.url}/${id}/contacts/${contactId}`); + } + + destroyCustomAttributes(id, customAttributes) { + return axios.post(`${this.url}/${id}/destroy_custom_attributes`, { + custom_attributes: customAttributes, + }); + } + + destroyAvatar(id) { + return axios.delete(`${this.url}/${id}/avatar`); + } } export default new CompanyAPI(); 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/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index 8f294a0ee..06b85078e 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -12,6 +12,7 @@ export const buildCreatePayload = ({ bccEmails = '', toEmails = '', templateParams, + isVoiceMessage = false, }) => { let payload; if (files && files.length !== 0) { @@ -33,6 +34,9 @@ export const buildCreatePayload = ({ if (contentAttributes) { payload.append('content_attributes', JSON.stringify(contentAttributes)); } + if (isVoiceMessage) { + payload.append('is_voice_message', true); + } } else { payload = { content: message, @@ -64,6 +68,7 @@ class MessageApi extends ApiClient { bccEmails = '', toEmails = '', templateParams, + isVoiceMessage = false, }) { return axios({ method: 'post', @@ -78,6 +83,7 @@ class MessageApi extends ApiClient { bccEmails, toEmails, templateParams, + isVoiceMessage, }), }); } diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index cc564fe96..3c1d17fc8 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -52,6 +52,20 @@ 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`); + } + + setInboundCalls(inboxId, enabled) { + return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, { + inbound_calls_enabled: enabled, + }); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/onboarding.js b/app/javascript/dashboard/api/onboarding.js new file mode 100644 index 000000000..e15d16da3 --- /dev/null +++ b/app/javascript/dashboard/api/onboarding.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class OnboardingAPI extends ApiClient { + constructor() { + super('onboarding', { accountScoped: true }); + } + + update(data) { + return axios.patch(this.url, data); + } + + getHelpCenterGeneration() { + return axios.get(`${this.url}/help_center_generation`); + } +} + +export default new OnboardingAPI(); 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/companies.spec.js b/app/javascript/dashboard/api/specs/companies.spec.js index 82fdc1c97..ca1d905de 100644 --- a/app/javascript/dashboard/api/specs/companies.spec.js +++ b/app/javascript/dashboard/api/specs/companies.spec.js @@ -1,7 +1,4 @@ -import companyAPI, { - buildCompanyParams, - buildSearchParams, -} from '../companies'; +import companyAPI from '../companies'; import ApiClient from '../ApiClient'; describe('#CompanyAPI', () => { @@ -9,7 +6,6 @@ describe('#CompanyAPI', () => { expect(companyAPI).toBeInstanceOf(ApiClient); expect(companyAPI).toHaveProperty('get'); expect(companyAPI).toHaveProperty('show'); - expect(companyAPI).toHaveProperty('create'); expect(companyAPI).toHaveProperty('update'); expect(companyAPI).toHaveProperty('delete'); expect(companyAPI).toHaveProperty('search'); @@ -32,111 +28,69 @@ describe('#CompanyAPI', () => { window.axios = originalAxios; }); - it('#get with default params', () => { + it('#get includes pagination and sorting params', () => { companyAPI.get({}); expect(axiosMock.get).toHaveBeenCalledWith( '/api/v1/companies?page=1&sort=name' ); }); - it('#get with page and sort params', () => { - companyAPI.get({ page: 2, sort: 'domain' }); - expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/companies?page=2&sort=domain' - ); - }); - - it('#get with descending sort', () => { - companyAPI.get({ page: 1, sort: '-created_at' }); - expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/companies?page=1&sort=-created_at' - ); - }); - - it('#search with query', () => { - companyAPI.search('acme', 1, 'name'); - expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/companies/search?q=acme&page=1&sort=name' - ); - }); - - it('#search with special characters in query', () => { + it('#search encodes query params', () => { companyAPI.search('acme & co', 2, 'domain'); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain' + '/api/v1/companies/search?q=acme+%26+co&page=2&sort=domain' ); }); - it('#search with descending sort', () => { - companyAPI.search('test', 1, '-created_at'); - expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/companies/search?q=test&page=1&sort=-created_at' - ); - }); - - it('#search with empty query', () => { + it('#search keeps empty query param for backend validation', () => { companyAPI.search('', 1, 'name'); expect(axiosMock.get).toHaveBeenCalledWith( '/api/v1/companies/search?q=&page=1&sort=name' ); }); - }); -}); -describe('#buildCompanyParams', () => { - it('returns correct string with page only', () => { - expect(buildCompanyParams(1)).toBe('page=1'); - }); - - it('returns correct string with page and sort', () => { - expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name'); - }); - - it('returns correct string with different page', () => { - expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain'); - }); - - it('returns correct string with descending sort', () => { - expect(buildCompanyParams(1, '-created_at')).toBe( - 'page=1&sort=-created_at' - ); - }); - - it('returns correct string without sort parameter', () => { - expect(buildCompanyParams(2, '')).toBe('page=2'); - }); -}); - -describe('#buildSearchParams', () => { - it('returns correct string with all parameters', () => { - expect(buildSearchParams('acme', 1, 'name')).toBe( - 'q=acme&page=1&sort=name' - ); - }); - - it('returns correct string with special characters', () => { - expect(buildSearchParams('acme & co', 2, 'domain')).toBe( - 'q=acme%20%26%20co&page=2&sort=domain' - ); - }); - - it('returns correct string with empty query', () => { - expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name'); - }); - - it('returns correct string without sort parameter', () => { - expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1'); - }); - - it('returns correct string with descending sort', () => { - expect(buildSearchParams('company', 3, '-created_at')).toBe( - 'q=company&page=3&sort=-created_at' - ); - }); - - it('encodes special characters correctly', () => { - expect(buildSearchParams('test@example.com', 1, 'name')).toBe( - 'q=test%40example.com&page=1&sort=name' - ); + it('#destroyAvatar deletes the company avatar endpoint', () => { + companyAPI.destroyAvatar(1); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/companies/1/avatar' + ); + }); + + it('#listContacts fetches company contacts', () => { + companyAPI.listContacts(1, 2); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/1/contacts?page=2' + ); + }); + + it('#searchContacts encodes contact search params', () => { + companyAPI.searchContacts(1, 'jane & co', 3); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/1/contacts/search?q=jane+%26+co&page=3' + ); + }); + + it('#createContact links a contact to the company', () => { + companyAPI.createContact(1, { contact_id: 2 }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/companies/1/contacts', + { contact_id: 2 } + ); + }); + + it('#removeContact unlinks a contact from the company', () => { + companyAPI.removeContact(1, 2); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/companies/1/contacts/2' + ); + }); + + it('#destroyCustomAttributes removes company custom attributes', () => { + companyAPI.destroyCustomAttributes(1, ['plan']); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/companies/1/destroy_custom_attributes', + { custom_attributes: ['plan'] } + ); + }); }); }); 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/api/specs/inbox/message.spec.js b/app/javascript/dashboard/api/specs/inbox/message.spec.js index 941f5c99c..84c0b9cf2 100644 --- a/app/javascript/dashboard/api/specs/inbox/message.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/message.spec.js @@ -83,5 +83,29 @@ describe('#ConversationAPI', () => { template_params: undefined, }); }); + + it('appends is_voice_message when isVoiceMessage is true', () => { + const formPayload = buildCreatePayload({ + message: 'voice message', + echoId: 42, + isPrivate: false, + files: [new Blob(['audio-data'], { type: 'audio/ogg' })], + isVoiceMessage: true, + }); + expect(formPayload).toBeInstanceOf(FormData); + expect(formPayload.get('is_voice_message')).toEqual('true'); + }); + + it('does not append is_voice_message when isVoiceMessage is false', () => { + const formPayload = buildCreatePayload({ + message: 'regular audio', + echoId: 43, + isPrivate: false, + files: [new Blob(['audio-data'], { type: 'audio/ogg' })], + isVoiceMessage: false, + }); + expect(formPayload).toBeInstanceOf(FormData); + expect(formPayload.get('is_voice_message')).toBeNull(); + }); }); }); diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue index 05507fc89..437933e5f 100644 --- a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue +++ b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue @@ -49,6 +49,7 @@ const emit = defineEmits(['edit', 'delete']); const { t } = useI18n(); const STATUS_COMPLETED = 'completed'; +const STATUS_PROCESSING = 'processing'; const { formatMessage } = useMessageFormatter(); @@ -68,9 +69,15 @@ const campaignStatus = computed(() => { : t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED'); } - return props.status === STATUS_COMPLETED - ? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED') - : t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED'); + if (props.status === STATUS_COMPLETED) { + return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED'); + } + + if (props.status === STATUS_PROCESSING) { + return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING'); + } + + return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED'); }); const inboxName = computed(() => props.inbox?.name || ''); diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue index 4603521eb..d81b997c5 100644 --- a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue +++ b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue @@ -1,7 +1,7 @@