Attachments:
- <% @large_attachments.each do |attachment| %> -<%= attachment.file.filename.to_s %>
- <% end %> +Attachments:
+<% @large_attachments.each do |attachment| %> +<%= attachment.file.filename.to_s %>
+<% end %> <% end %> diff --git a/config/application.rb b/config/application.rb index d644dd28f..aa150794a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -75,7 +75,11 @@ module Chatwoot config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'] config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', nil) config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', nil) + # TODO: Remove once encryption is mandatory and legacy plaintext is migrated. config.active_record.encryption.support_unencrypted_data = true + # Extend deterministic queries so they match both encrypted and plaintext rows + config.active_record.encryption.extend_queries = true + # Store a per-row key reference to support future key rotation config.active_record.encryption.store_key_references = true end end @@ -94,6 +98,8 @@ module Chatwoot end def self.encryption_configured? + # TODO: Once Active Record encryption keys are mandatory (target 3-4 releases out), + # remove this guard and assume encryption is always enabled. # Check if proper encryption keys are configured # MFA/2FA features should only be enabled when proper keys are set ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? && diff --git a/config/locales/en.yml b/config/locales/en.yml index 0c76f8beb..ad54b8dfa 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -76,6 +76,9 @@ en: invalid: Invalid email phone_number: invalid: should be in e164 format + companies: + domain: + invalid: must be a valid domain name categories: locale: unique: should be unique in the category and portal @@ -199,6 +202,8 @@ en: captain: resolved: 'Conversation was marked resolved by %{user_name} due to inactivity' open: 'Conversation was marked open by %{user_name}' + agent_bot: + error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.' status: resolved: 'Conversation was marked resolved by %{user_name}' contact_resolved: 'Conversation was resolved by %{contact_name}' diff --git a/config/routes.rb b/config/routes.rb index 757d20620..639c51da7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -153,6 +153,7 @@ Rails.application.routes.draw do end end + resources :companies, only: [:index, :show, :create, :update, :destroy] resources :contacts, only: [:index, :show, :update, :create, :destroy] do collection do get :active diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 50a47a20b..138cf78b3 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -27,6 +27,7 @@ - purgable - housekeeping - async_database_migration + - bulk_reindex_low - active_storage_analysis - active_storage_purge - action_mailbox_incineration diff --git a/db/migrate/20250929105219_create_companies.rb b/db/migrate/20250929105219_create_companies.rb new file mode 100644 index 000000000..10fa415c1 --- /dev/null +++ b/db/migrate/20250929105219_create_companies.rb @@ -0,0 +1,14 @@ +class CreateCompanies < ActiveRecord::Migration[7.1] + def change + create_table :companies do |t| + t.string :name, null: false + t.string :domain + t.text :description + t.references :account, null: false + + t.timestamps + end + add_index :companies, [:name, :account_id] + add_index :companies, [:domain, :account_id] + end +end diff --git a/db/migrate/20250929132305_add_company_to_contacts.rb b/db/migrate/20250929132305_add_company_to_contacts.rb new file mode 100644 index 000000000..e79de34b8 --- /dev/null +++ b/db/migrate/20250929132305_add_company_to_contacts.rb @@ -0,0 +1,5 @@ +class AddCompanyToContacts < ActiveRecord::Migration[7.1] + def change + add_reference :contacts, :company, null: true + end +end diff --git a/db/schema.rb b/db/schema.rb index f31d05cc3..c0d539f6a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -570,6 +570,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true end + create_table "companies", force: :cascade do |t| + t.string "name", null: false + t.string "domain" + t.text "description" + t.bigint "account_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_companies_on_account_id" + t.index ["domain", "account_id"], name: "index_companies_on_domain_and_account_id" + t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id" + end + create_table "contact_inboxes", force: :cascade do |t| t.bigint "contact_id" t.bigint "inbox_id" @@ -602,6 +614,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do t.string "location", default: "" t.string "country_code", default: "" t.boolean "blocked", default: false, null: false + t.bigint "company_id" t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id" t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type" t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" @@ -609,6 +622,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do t.index ["account_id"], name: "index_contacts_on_account_id" t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" t.index ["blocked"], name: "index_contacts_on_blocked" + t.index ["company_id"], name: "index_contacts_on_company_id" t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin diff --git a/enterprise/app/controllers/api/v1/accounts/companies_controller.rb b/enterprise/app/controllers/api/v1/accounts/companies_controller.rb new file mode 100644 index 000000000..a33e4c6b2 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/companies_controller.rb @@ -0,0 +1,40 @@ +class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController + before_action :check_authorization + before_action :fetch_company, only: [:show, :update, :destroy] + + def index + @companies = Current.account.companies.ordered_by_name + end + + def show; end + + def create + @company = Current.account.companies.build(company_params) + @company.save! + end + + def update + @company.update!(company_params) + end + + def destroy + @company.destroy! + head :ok + end + + private + + def check_authorization + raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise? + + authorize(Company) + end + + def fetch_company + @company = Current.account.companies.find(params[:id]) + end + + def company_params + params.require(:company).permit(:name, :domain, :description, :avatar) + end +end diff --git a/enterprise/app/controllers/api/v1/auth_controller.rb b/enterprise/app/controllers/api/v1/auth_controller.rb index 091d4f8f8..b0d8e1366 100644 --- a/enterprise/app/controllers/api/v1/auth_controller.rb +++ b/enterprise/app/controllers/api/v1/auth_controller.rb @@ -5,7 +5,9 @@ class Api::V1::AuthController < Api::BaseController def saml_login return if @account.nil? - saml_initiation_url = "/auth/saml?account_id=#{@account.id}" + relay_state = params[:target] || 'web' + + saml_initiation_url = "/auth/saml?account_id=#{@account.id}&RelayState=#{relay_state}" redirect_to saml_initiation_url, status: :temporary_redirect end @@ -44,7 +46,18 @@ class Api::V1::AuthController < Api::BaseController end def render_saml_error - redirect_to sso_login_page_url(error: 'saml-authentication-failed') + error = 'saml-authentication-failed' + + if mobile_target? + mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp') + redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true + else + redirect_to sso_login_page_url(error: error) + end + end + + def mobile_target? + params[:target]&.casecmp('mobile')&.zero? end def sso_login_page_url(error: nil) diff --git a/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb index 973f26650..4856ca443 100644 --- a/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb +++ b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb @@ -32,17 +32,40 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController end end + def omniauth_failure + return super unless params[:provider] == 'saml' + + relay_state = saml_relay_state + error = params[:message] || 'authentication-failed' + + if for_mobile?(relay_state) + redirect_to_mobile_error(error, relay_state) + else + redirect_to login_page_url(error: "saml-#{error}") + end + end + private def handle_saml_auth account_id = extract_saml_account_id - return redirect_to login_page_url(error: 'saml-not-enabled') unless saml_enabled_for_account?(account_id) + relay_state = saml_relay_state + + unless saml_enabled_for_account?(account_id) + return redirect_to_mobile_error('saml-not-enabled') if for_mobile?(relay_state) + + return redirect_to login_page_url(error: 'saml-not-enabled') + end @resource = SamlUserBuilder.new(auth_hash, account_id).perform if @resource.persisted? + return sign_in_user_on_mobile if for_mobile?(relay_state) + sign_in_user else + return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state) + redirect_to login_page_url(error: 'saml-authentication-failed') end end @@ -51,6 +74,19 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id') end + def saml_relay_state + session[:saml_relay_state] || request.env['omniauth.params']&.dig('RelayState') + end + + def for_mobile?(relay_state) + relay_state.to_s.casecmp('mobile').zero? + end + + def redirect_to_mobile_error(error) + mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp') + redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true + end + def saml_enabled_for_account?(account_id) return false if account_id.blank? diff --git a/enterprise/app/models/company.rb b/enterprise/app/models/company.rb new file mode 100644 index 000000000..764cb2a9c --- /dev/null +++ b/enterprise/app/models/company.rb @@ -0,0 +1,33 @@ +# == Schema Information +# +# Table name: companies +# +# id :bigint not null, primary key +# description :text +# domain :string +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# +# Indexes +# +# index_companies_on_account_id (account_id) +# index_companies_on_domain_and_account_id (domain,account_id) +# index_companies_on_name_and_account_id (name,account_id) +# +class Company < ApplicationRecord + include Avatarable + validates :account_id, presence: true + validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT } + validates :domain, allow_blank: true, format: { + with: /\A[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+\z/, + message: I18n.t('errors.companies.domain.invalid') + } + validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT } + + belongs_to :account + has_many :contacts, dependent: :nullify + + scope :ordered_by_name, -> { order(:name) } +end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index b82d84b0a..cae32e86c 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -13,6 +13,7 @@ module Enterprise::Concerns::Account has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool' has_many :copilot_threads, dependent: :destroy_async + has_many :companies, dependent: :destroy_async has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice' has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings' diff --git a/enterprise/app/models/enterprise/concerns/contact.rb b/enterprise/app/models/enterprise/concerns/contact.rb new file mode 100644 index 000000000..9139fc67e --- /dev/null +++ b/enterprise/app/models/enterprise/concerns/contact.rb @@ -0,0 +1,6 @@ +module Enterprise::Concerns::Contact + extend ActiveSupport::Concern + included do + belongs_to :company, optional: true + end +end diff --git a/enterprise/app/policies/company_policy.rb b/enterprise/app/policies/company_policy.rb new file mode 100644 index 000000000..1c252967c --- /dev/null +++ b/enterprise/app/policies/company_policy.rb @@ -0,0 +1,21 @@ +class CompanyPolicy < ApplicationPolicy + def index? + true + end + + def show? + true + end + + def create? + true + end + + def update? + true + end + + def destroy? + @account_user.administrator? + end +end diff --git a/enterprise/app/views/api/v1/accounts/companies/_company.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/_company.json.jbuilder new file mode 100644 index 000000000..71c4d3b9b --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/companies/_company.json.jbuilder @@ -0,0 +1,7 @@ +json.id company.id +json.name company.name +json.domain company.domain +json.description company.description +json.avatar_url company.avatar_url +json.created_at company.created_at +json.updated_at company.updated_at diff --git a/enterprise/app/views/api/v1/accounts/companies/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/create.json.jbuilder new file mode 100644 index 000000000..b3bc80cfd --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/companies/create.json.jbuilder @@ -0,0 +1,3 @@ +json.payload do + json.partial! 'company', company: @company +end diff --git a/enterprise/app/views/api/v1/accounts/companies/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/index.json.jbuilder new file mode 100644 index 000000000..e68bd8543 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/companies/index.json.jbuilder @@ -0,0 +1,5 @@ +json.payload do + json.array! @companies do |company| + json.partial! 'company', company: company + end +end diff --git a/enterprise/app/views/api/v1/accounts/companies/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/show.json.jbuilder new file mode 100644 index 000000000..b3bc80cfd --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/companies/show.json.jbuilder @@ -0,0 +1,3 @@ +json.payload do + json.partial! 'company', company: @company +end diff --git a/enterprise/app/views/api/v1/accounts/companies/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/update.json.jbuilder new file mode 100644 index 000000000..b3bc80cfd --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/companies/update.json.jbuilder @@ -0,0 +1,3 @@ +json.payload do + json.partial! 'company', company: @company +end diff --git a/enterprise/config/initializers/omniauth_saml.rb b/enterprise/config/initializers/omniauth_saml.rb index f73e3a109..f39e9d511 100644 --- a/enterprise/config/initializers/omniauth_saml.rb +++ b/enterprise/config/initializers/omniauth_saml.rb @@ -9,18 +9,22 @@ SAML_SETUP_PROC = proc do |env| account_id = request.params['account_id'] || request.session[:saml_account_id] || env['omniauth.params']&.dig('account_id') + relay_state = request.params['RelayState'] || '' if account_id # Store in session and omniauth params for callback request.session[:saml_account_id] = account_id + request.session[:saml_relay_state] = relay_state env['omniauth.params'] ||= {} env['omniauth.params']['account_id'] = account_id + env['omniauth.params']['RelayState'] = relay_state # Find SAML settings for this account settings = AccountSamlSettings.find_by(account_id: account_id) if settings # Configure the strategy options dynamically + env['omniauth.strategy'].options[:idp_sso_service_url_runtime_params] = { RelayState: :RelayState } env['omniauth.strategy'].options[:assertion_consumer_service_url] = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/omniauth/saml/callback?account_id=#{account_id}" env['omniauth.strategy'].options[:sp_entity_id] = settings.sp_entity_id env['omniauth.strategy'].options[:idp_entity_id] = settings.idp_entity_id diff --git a/lib/integrations/slack/slack_message_helper.rb b/lib/integrations/slack/slack_message_helper.rb index 52ec4caad..0ee328fb3 100644 --- a/lib/integrations/slack/slack_message_helper.rb +++ b/lib/integrations/slack/slack_message_helper.rb @@ -70,7 +70,9 @@ module Integrations::Slack::SlackMessageHelper case attachment[:filetype] when 'png', 'jpeg', 'gif', 'bmp', 'tiff', 'jpg' :image - when 'pdf' + when 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm' + :video + else :file end end diff --git a/lib/limits.rb b/lib/limits.rb index 5da178bf4..c0fc03806 100644 --- a/lib/limits.rb +++ b/lib/limits.rb @@ -6,6 +6,8 @@ module Limits GREETING_MESSAGE_MAX_LENGTH = 10_000 CATEGORIES_PER_PAGE = 1000 AUTO_ASSIGNMENT_BULK_LIMIT = 100 + COMPANY_NAME_LENGTH_LIMIT = 100 + COMPANY_DESCRIPTION_LENGTH_LIMIT = 1000 MAX_CUSTOM_FILTERS_PER_USER = 1000 def self.conversation_message_per_minute_limit diff --git a/lib/seeders/reports/conversation_creator.rb b/lib/seeders/reports/conversation_creator.rb index b6259de7d..1cd11ef33 100644 --- a/lib/seeders/reports/conversation_creator.rb +++ b/lib/seeders/reports/conversation_creator.rb @@ -16,8 +16,11 @@ class Seeders::Reports::ConversationCreator @priorities = [nil, 'urgent', 'high', 'medium', 'low'] end + # rubocop:disable Metrics/MethodLength def create_conversation(created_at:) conversation = nil + should_resolve = false + resolution_time = nil ActiveRecord::Base.transaction do travel_to(created_at) do @@ -26,14 +29,35 @@ class Seeders::Reports::ConversationCreator add_labels_to_conversation(conversation) create_messages_for_conversation(conversation) - resolve_conversation_if_needed(conversation) + + # Determine if should resolve but don't update yet + should_resolve = rand > 0.3 + if should_resolve + resolution_delay = rand((30.minutes)..(24.hours)) + resolution_time = created_at + resolution_delay + end end travel_back end + # Now resolve outside of time travel if needed + if should_resolve && resolution_time + # rubocop:disable Rails/SkipsModelValidations + conversation.update_column(:status, :resolved) + conversation.update_column(:updated_at, resolution_time) + # rubocop:enable Rails/SkipsModelValidations + + # Trigger the event with proper timestamp + travel_to(resolution_time) do + trigger_conversation_resolved_event(conversation) + end + travel_back + end + conversation end + # rubocop:enable Metrics/MethodLength private @@ -85,16 +109,6 @@ class Seeders::Reports::ConversationCreator message_creator.create_messages end - def resolve_conversation_if_needed(conversation) - return unless rand < 0.7 - - resolution_delay = rand((30.minutes)..(24.hours)) - travel(resolution_delay) - conversation.update!(status: :resolved) - - trigger_conversation_resolved_event(conversation) - end - def trigger_conversation_resolved_event(conversation) event_data = { conversation: conversation } diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb index 41b3a415d..95c399d54 100644 --- a/lib/webhooks/trigger.rb +++ b/lib/webhooks/trigger.rb @@ -31,14 +31,33 @@ class Webhooks::Trigger end def handle_error(error) - return unless should_handle_error? + return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event]) return unless message - update_message_status(error) + case @webhook_type + when :agent_bot_webhook + conversation = message.conversation + return unless conversation&.pending? + + conversation.open! + create_agent_bot_error_activity(conversation) + when :api_inbox_webhook + update_message_status(error) + end end - def should_handle_error? - @webhook_type == :api_inbox_webhook && SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event]) + def create_agent_bot_error_activity(conversation) + content = I18n.t('conversations.activity.agent_bot.error_moved_to_open') + Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content)) + end + + def activity_message_params(conversation, content) + { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :activity, + content: content + } end def update_message_status(error) diff --git a/script/bulk_reindex_messages.rb b/script/bulk_reindex_messages.rb new file mode 100644 index 000000000..1e19f70a7 --- /dev/null +++ b/script/bulk_reindex_messages.rb @@ -0,0 +1,58 @@ +# Bulk reindex all messages with throttling to prevent DB overload +# This creates jobs slowly to avoid overwhelming the database connection pool +# Usage: RAILS_ENV=production POSTGRES_STATEMENT_TIMEOUT=6000s bundle exec rails runner script/bulk_reindex_messages.rb + +JOBS_PER_MINUTE = 50 # Adjust based on your DB capacity +BATCH_SIZE = 1000 # Messages per job + +batch_count = 0 +total_batches = (Message.count / BATCH_SIZE.to_f).ceil +start_time = Time.zone.now + +index_name = Message.searchkick_index.name + +puts '=' * 80 +puts "Bulk Reindex Started at #{start_time}" +puts '=' * 80 +puts "Total messages: #{Message.count}" +puts "Batch size: #{BATCH_SIZE}" +puts "Total batches: #{total_batches}" +puts "Index name: #{index_name}" +puts "Rate: #{JOBS_PER_MINUTE} jobs/minute (#{JOBS_PER_MINUTE * BATCH_SIZE} messages/minute)" +puts "Estimated time: #{(total_batches / JOBS_PER_MINUTE.to_f / 60).round(2)} hours" +puts '=' * 80 +puts '' + +sleep(15) + +Message.find_in_batches(batch_size: BATCH_SIZE).with_index do |batch, index| + batch_count += 1 + + # Enqueue to low priority queue with proper format + Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later( + class_name: 'Message', + index_name: index_name, + batch_id: index, + record_ids: batch.map(&:id) # Keep as integers like Message.reindex does + ) + + # Throttle: wait after every N jobs + if (batch_count % JOBS_PER_MINUTE).zero? + elapsed = Time.zone.now - start_time + progress = (batch_count.to_f / total_batches * 100).round(2) + queue_size = Sidekiq::Queue.new('bulk_reindex_low').size + + puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}] Progress: #{batch_count}/#{total_batches} (#{progress}%)" + puts " Queue size: #{queue_size}" + puts " Elapsed: #{(elapsed / 3600).round(2)} hours" + puts " ETA: #{((elapsed / batch_count * (total_batches - batch_count)) / 3600).round(2)} hours remaining" + puts '' + + sleep(60) + end +end + +puts '=' * 80 +puts "Done! Created #{batch_count} jobs" +puts "Total time: #{((Time.zone.now - start_time) / 3600).round(2)} hours" +puts '=' * 80 diff --git a/script/monitor_reindex.rb b/script/monitor_reindex.rb new file mode 100644 index 000000000..6a2c1ee6c --- /dev/null +++ b/script/monitor_reindex.rb @@ -0,0 +1,19 @@ +# Monitor bulk reindex progress +# RAILS_ENV=production bundle exec rails runner script/monitor_reindex.rb + +puts 'Monitoring bulk reindex progress (Ctrl+C to stop)...' +puts '' + +loop do + bulk_queue = Sidekiq::Queue.new('bulk_reindex_low') + prod_queue = Sidekiq::Queue.new('async_database_migration') + retry_set = Sidekiq::RetrySet.new + + puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}]" + puts " Bulk Reindex Queue: #{bulk_queue.size} jobs" + puts " Production Queue: #{prod_queue.size} jobs" + puts " Retry Queue: #{retry_set.size} jobs" + puts " #{('-' * 60)}" + + sleep(30) +end diff --git a/script/reindex_single_account.rb b/script/reindex_single_account.rb new file mode 100644 index 000000000..cb7dd8c87 --- /dev/null +++ b/script/reindex_single_account.rb @@ -0,0 +1,58 @@ +# Reindex messages for a single account +# Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK] + +#account_id = ARGV[0]&.to_i +days_back = (ARGV[1] || 30).to_i + +# if account_id.nil? || account_id.zero? +# puts "Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK]" +# puts "Example: bundle exec rails runner script/reindex_single_account.rb 93293 30" +# exit 1 +# end + +# account = Account.find(account_id) +# puts "=" * 80 +# puts "Reindexing messages for: #{account.name} (ID: #{account.id})" +# puts "=" * 80 + +# Enable feature if not already enabled +# unless account.feature_enabled?('advanced_search_indexing') +# puts "Enabling advanced_search_indexing feature..." +# account.enable_features(:advanced_search_indexing) +# account.save! +# end + +# Get messages to index +# messages = Message.where(account_id: account.id) +# .where(message_type: [0, 1]) # incoming/outgoing only +# .where('created_at >= ?', days_back.days.ago) + +messages = Message.where('created_at >= ?', days_back.days.ago) + +puts "Found #{messages.count} messages to index (last #{days_back} days)" +puts '' + +sleep(15) + +# Create bulk reindex jobs +index_name = Message.searchkick_index.name +batch_count = 0 + +messages.find_in_batches(batch_size: 1000).with_index do |batch, index| + Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later( + class_name: 'Message', + index_name: index_name, + batch_id: index, + record_ids: batch.map(&:id) + ) + + batch_count += 1 + print '.' + sleep(0.5) # Small delay +end + +puts '' +puts '=' * 80 +puts "Done! Created #{batch_count} bulk reindex jobs" +puts 'Messages will be indexed shortly via the bulk_reindex_low queue' +puts '=' * 80 diff --git a/spec/builders/messages/message_builder_spec.rb b/spec/builders/messages/message_builder_spec.rb index 891f8eb02..2eb4dbf90 100644 --- a/spec/builders/messages/message_builder_spec.rb +++ b/spec/builders/messages/message_builder_spec.rb @@ -179,6 +179,63 @@ describe Messages::MessageBuilder do expect(message.content_attributes[:cc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com'] expect(message.content_attributes[:bcc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com'] end + + context 'when custom email content is provided' do + before do + account.enable_features('quoted_email_reply') + end + + it 'creates message with custom HTML email content' do + params = ActionController::Parameters.new({ + content: 'Regular message content', + email_html_content: 'Custom HTML content
' + }) + + message = described_class.new(user, conversation, params).perform + + expect(message.content_attributes.dig('email', 'html_content', 'full')).to eq 'Custom HTML content
' + expect(message.content_attributes.dig('email', 'html_content', 'reply')).to eq 'Custom HTML content
' + expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular message content' + expect(message.content_attributes.dig('email', 'text_content', 'reply')).to eq 'Regular message content' + end + + it 'does not process custom email content when quoted_email_reply feature is disabled' do + account.disable_features('quoted_email_reply') + params = ActionController::Parameters.new({ + content: 'Regular message content', + email_html_content: 'Custom HTML content
' + }) + + message = described_class.new(user, conversation, params).perform + + expect(message.content_attributes.dig('email', 'html_content')).to be_nil + expect(message.content_attributes.dig('email', 'text_content')).to be_nil + end + + it 'does not process custom email content for private messages' do + params = ActionController::Parameters.new({ + content: 'Regular message content', + email_html_content: 'Custom HTML content
', + private: true + }) + + message = described_class.new(user, conversation, params).perform + + expect(message.content_attributes.dig('email', 'html_content')).to be_nil + expect(message.content_attributes.dig('email', 'text_content')).to be_nil + end + + it 'falls back to default behavior when no custom email content is provided' do + params = ActionController::Parameters.new({ + content: 'Regular **markdown** content' + }) + + message = described_class.new(user, conversation, params).perform + + expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('markdown') + expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular **markdown** content' + end + end end end end diff --git a/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb new file mode 100644 index 000000000..f62991ad1 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb @@ -0,0 +1,141 @@ +require 'rails_helper' + +RSpec.describe 'Companies API', type: :request do + let(:account) { create(:account) } + + describe 'GET /api/v1/accounts/{account.id}/companies' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/companies" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:admin) { create(:user, account: account, role: :administrator) } + let!(:company1) { create(:company, name: 'Company 1', account: account) } + let!(:company2) { create(:company, account: account) } + + it 'returns all companies' do + get "/api/v1/accounts/#{account.id}/companies", + headers: admin.create_new_auth_token, + as: :json + expect(response).to have_http_status(:success) + response_body = response.parsed_body + expect(response_body['payload'].size).to eq(2) + expect(response_body['payload'].map { |c| c['name'] }).to contain_exactly(company1.name, company2.name) + end + end + end + + describe 'GET /api/v1/accounts/{account.id}/companies/{id}' do + context 'when it is an authenticated user' do + let(:admin) { create(:user, account: account, role: :administrator) } + let(:company) { create(:company, account: account) } + + it 'returns the company' do + get "/api/v1/accounts/#{account.id}/companies/#{company.id}", + headers: admin.create_new_auth_token, + as: :json + expect(response).to have_http_status(:success) + response_body = response.parsed_body + expect(response_body['payload']['name']).to eq(company.name) + expect(response_body['payload']['id']).to eq(company.id) + end + end + end + + describe 'POST /api/v1/accounts/{account.id}/companies' do + context 'when it is an authenticated user' do + let(:admin) { create(:user, account: account, role: :administrator) } + let(:valid_params) do + { + company: { + name: 'New Company', + domain: 'newcompany.com', + description: 'A new company' + } + } + end + + it 'creates a new company' do + expect do + post "/api/v1/accounts/#{account.id}/companies", + params: valid_params, + headers: admin.create_new_auth_token, + as: :json + end.to change(Company, :count).by(1) + + expect(response).to have_http_status(:success) + response_body = response.parsed_body + expect(response_body['payload']['name']).to eq('New Company') + expect(response_body['payload']['domain']).to eq('newcompany.com') + end + + it 'returns error for invalid params' do + invalid_params = { company: { name: '' } } + + post "/api/v1/accounts/#{account.id}/companies", + params: invalid_params, + headers: admin.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + describe 'PATCH /api/v1/accounts/{account.id}/companies/{id}' do + context 'when it is an authenticated user' do + let(:admin) { create(:user, account: account, role: :administrator) } + let(:company) { create(:company, account: account) } + let(:update_params) do + { + company: { + name: 'Updated Company Name', + domain: 'updated.com' + } + } + end + + it 'updates the company' do + patch "/api/v1/accounts/#{account.id}/companies/#{company.id}", + params: update_params, + headers: admin.create_new_auth_token, + as: :json + expect(response).to have_http_status(:success) + response_body = response.parsed_body + expect(response_body['payload']['name']).to eq('Updated Company Name') + expect(response_body['payload']['domain']).to eq('updated.com') + end + end + end + + describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}' do + context 'when it is an authenticated administrator' do + let(:admin) { create(:user, account: account, role: :administrator) } + let(:company) { create(:company, account: account) } + + it 'deletes the company' do + company + expect do + delete "/api/v1/accounts/#{account.id}/companies/#{company.id}", + headers: admin.create_new_auth_token, + as: :json + end.to change(Company, :count).by(-1) + expect(response).to have_http_status(:ok) + end + end + + context 'when it is a regular agent' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:company) { create(:company, account: account) } + + it 'returns unauthorized' do + delete "/api/v1/accounts/#{account.id}/companies/#{company.id}", + headers: agent.create_new_auth_token, + as: :json + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/enterprise/controllers/api/v1/auth_controller_spec.rb b/spec/enterprise/controllers/api/v1/auth_controller_spec.rb index f1e2ef1c7..767011a50 100644 --- a/spec/enterprise/controllers/api/v1/auth_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/auth_controller_spec.rb @@ -36,6 +36,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed') end + + it 'redirects to mobile deep link with error when target is mobile' do + post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com', target: 'mobile' } + + expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed') + end end context 'when user exists but has no SAML enabled accounts' do @@ -48,6 +54,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed') end + + it 'redirects to mobile deep link with error when target is mobile' do + post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' } + + expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed') + end end context 'when user has account without SAML feature enabled' do @@ -65,6 +77,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed') end + + it 'redirects to mobile deep link with error when target is mobile' do + post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' } + + expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed') + end end context 'when user has valid SAML configuration' do @@ -82,6 +100,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do expect(response.location).to include("/auth/saml?account_id=#{account.id}") end + + it 'redirects to SAML initiation URL with mobile relay state' do + post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' } + + expect(response.location).to include("/auth/saml?account_id=#{account.id}&RelayState=mobile") + end end context 'when user has multiple accounts with SAML' do diff --git a/spec/enterprise/models/company_spec.rb b/spec/enterprise/models/company_spec.rb new file mode 100644 index 000000000..820e6ee7d --- /dev/null +++ b/spec/enterprise/models/company_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe Company, type: :model do + context 'with validations' do + it { is_expected.to validate_presence_of(:account_id) } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_length_of(:name).is_at_most(100) } + it { is_expected.to validate_length_of(:description).is_at_most(1000) } + + describe 'domain validation' do + it { is_expected.to allow_value('example.com').for(:domain) } + it { is_expected.to allow_value('sub.example.com').for(:domain) } + it { is_expected.to allow_value('').for(:domain) } + it { is_expected.to allow_value(nil).for(:domain) } + it { is_expected.not_to allow_value('invalid-domain').for(:domain) } + it { is_expected.not_to allow_value('.example.com').for(:domain) } + end + end + + context 'with associations' do + it { is_expected.to belong_to(:account) } + it { is_expected.to have_many(:contacts).dependent(:nullify) } + end + + describe 'scopes' do + let(:account) { create(:account) } + let!(:company_b) { create(:company, name: 'B Company', account: account) } + let!(:company_a) { create(:company, name: 'A Company', account: account) } + let!(:company_c) { create(:company, name: 'C Company', account: account) } + + describe '.ordered_by_name' do + it 'orders companies by name alphabetically' do + companies = described_class.where(account: account).ordered_by_name + expect(companies.map(&:name)).to eq([company_a.name, company_b.name, company_c.name]) + end + end + end +end diff --git a/spec/enterprise/policies/company_policy_spec.rb b/spec/enterprise/policies/company_policy_spec.rb new file mode 100644 index 000000000..9f7d9f0cc --- /dev/null +++ b/spec/enterprise/policies/company_policy_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +RSpec.describe CompanyPolicy, type: :policy do + subject(:company_policy) { described_class } + + let(:account) { create(:account) } + let(:administrator) { create(:user, :administrator, account: account) } + let(:agent) { create(:user, account: account) } + let(:company) { create(:company, account: account) } + + let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } } + let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } } + + permissions :index?, :show?, :create?, :update? do + context 'when administrator' do + it { expect(company_policy).to permit(administrator_context, company) } + end + + context 'when agent' do + it { expect(company_policy).to permit(agent_context, company) } + end + end + + permissions :destroy? do + context 'when administrator' do + it { expect(company_policy).to permit(administrator_context, company) } + end + + context 'when agent' do + it { expect(company_policy).not_to permit(agent_context, company) } + end + end +end diff --git a/spec/factories/companies.rb b/spec/factories/companies.rb new file mode 100644 index 000000000..bdf7e9e9f --- /dev/null +++ b/spec/factories/companies.rb @@ -0,0 +1,20 @@ +FactoryBot.define do + factory :company do + sequence(:name) { |n| "Company #{n}" } + sequence(:domain) { |n| "company#{n}.com" } + description { 'A sample company description' } + account + + trait :without_domain do + domain { nil } + end + + trait :with_avatar do + avatar { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') } + end + + trait :with_long_description do + description { 'A' * 500 } + end + end +end diff --git a/spec/lib/integrations/slack/incoming_message_builder_spec.rb b/spec/lib/integrations/slack/incoming_message_builder_spec.rb index 608324e8f..2ce206489 100644 --- a/spec/lib/integrations/slack/incoming_message_builder_spec.rb +++ b/spec/lib/integrations/slack/incoming_message_builder_spec.rb @@ -157,6 +157,19 @@ describe Integrations::Slack::IncomingMessageBuilder do expect(conversation.messages.count).to eql(messages_count) end + + it 'handles different file types correctly' do + expect(hook).not_to be_nil + video_attachment_params = message_with_attachments.deep_dup + video_attachment_params[:event][:files][0][:filetype] = 'mp4' + video_attachment_params[:event][:files][0][:mimetype] = 'video/mp4' + + builder = described_class.new(video_attachment_params) + allow(builder).to receive(:sender).and_return(nil) + + expect { builder.perform }.not_to raise_error + expect(conversation.messages.last.attachments).to be_any + end end context 'when link shared' do diff --git a/spec/lib/webhooks/trigger_spec.rb b/spec/lib/webhooks/trigger_spec.rb index 8ff2a21a5..224a35e07 100644 --- a/spec/lib/webhooks/trigger_spec.rb +++ b/spec/lib/webhooks/trigger_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe Webhooks::Trigger do + include ActiveJob::TestHelper + subject(:trigger) { described_class } let!(:account) { create(:account) } @@ -8,8 +10,18 @@ describe Webhooks::Trigger do let!(:conversation) { create(:conversation, inbox: inbox) } let!(:message) { create(:message, account: account, inbox: inbox, conversation: conversation) } - let!(:webhook_type) { :api_inbox_webhook } + let(:webhook_type) { :api_inbox_webhook } let!(:url) { 'https://test.com' } + let(:agent_bot_error_content) { I18n.t('conversations.activity.agent_bot.error_moved_to_open') } + + before do + ActiveJob::Base.queue_adapter = :test + end + + after do + clear_enqueued_jobs + clear_performed_jobs + end describe '#execute' do it 'triggers webhook' do @@ -54,6 +66,57 @@ describe Webhooks::Trigger do ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed') end + + context 'when webhook type is agent bot' do + let(:webhook_type) { :agent_bot_webhook } + + it 'reopens conversation and enqueues activity message if pending' do + conversation.update(status: :pending) + payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id } + + expect(RestClient::Request).to receive(:execute) + .with( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: 5 + ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once + + expect do + perform_enqueued_jobs do + trigger.execute(url, payload, webhook_type) + end + end.not_to(change { message.reload.status }) + + expect(conversation.reload.status).to eq('open') + + activity_message = conversation.reload.messages.order(:created_at).last + expect(activity_message.message_type).to eq('activity') + expect(activity_message.content).to eq(agent_bot_error_content) + end + + it 'does not change message status or enqueue activity when conversation is not pending' do + payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id } + + expect(RestClient::Request).to receive(:execute) + .with( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: 5 + ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once + + expect do + trigger.execute(url, payload, webhook_type) + end.not_to(change { message.reload.status }) + + expect(Conversations::ActivityMessageJob).not_to have_been_enqueued + + expect(conversation.reload.status).to eq('open') + end + end end it 'does not update message status if webhook fails for other events' do diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb index ecd97333e..3f6395566 100644 --- a/spec/mailers/conversation_reply_mailer_spec.rb +++ b/spec/mailers/conversation_reply_mailer_spec.rb @@ -335,6 +335,118 @@ RSpec.describe ConversationReplyMailer do expect(mail.body.encoded).not_to match(%r{]*>avatar\.png}) end end + + context 'with custom email content' do + it 'uses custom HTML content when available and creates multipart email' do + message_with_custom_content = create(:message, + conversation: conversation, + account: account, + message_type: 'outgoing', + content: 'Regular message content', + content_attributes: { + email: { + html_content: { + reply: 'Custom HTML content for email
' + }, + text_content: { + reply: 'Custom text content for email' + } + } + }) + + mail = described_class.email_reply(message_with_custom_content).deliver_now + + # Check HTML part contains custom HTML content + html_part = mail.html_part || mail + expect(html_part.body.encoded).to include('Custom HTML content for email
') + expect(html_part.body.encoded).not_to include('Regular message content') + + # Check text part contains custom text content + text_part = mail.text_part + if text_part + expect(text_part.body.encoded).to include('Custom text content for email') + expect(text_part.body.encoded).not_to include('Regular message content') + end + end + + it 'falls back to markdown rendering when custom HTML content is not available' do + message_without_custom_content = create(:message, + conversation: conversation, + account: account, + message_type: 'outgoing', + content: 'Regular **markdown** content') + + mail = described_class.email_reply(message_without_custom_content).deliver_now + + html_part = mail.html_part || mail + expect(html_part.body.encoded).to include('markdown') + expect(html_part.body.encoded).to include('Regular') + end + + it 'handles empty custom HTML content gracefully' do + message_with_empty_content = create(:message, + conversation: conversation, + account: account, + message_type: 'outgoing', + content: 'Regular **markdown** content', + content_attributes: { + email: { + html_content: { + reply: '' + } + } + }) + + mail = described_class.email_reply(message_with_empty_content).deliver_now + + html_part = mail.html_part || mail + expect(html_part.body.encoded).to include('markdown') + expect(html_part.body.encoded).to include('Regular') + end + + it 'handles nil custom HTML content gracefully' do + message_with_nil_content = create(:message, + conversation: conversation, + account: account, + message_type: 'outgoing', + content: 'Regular **markdown** content', + content_attributes: { + email: { + html_content: { + reply: nil + } + } + }) + + mail = described_class.email_reply(message_with_nil_content).deliver_now + + expect(mail.body.encoded).to include('markdown') + expect(mail.body.encoded).to include('Regular') + end + + it 'uses custom text content in text part when only text is provided' do + message_with_text_only = create(:message, + conversation: conversation, + account: account, + message_type: 'outgoing', + content: 'Regular message content', + content_attributes: { + email: { + text_content: { + reply: 'Custom text content only' + } + } + }) + + mail = described_class.email_reply(message_with_text_only).deliver_now + + text_part = mail.text_part + if text_part + expect(text_part.body.encoded).to include('Custom text content only') + expect(text_part.body.encoded).not_to include('Regular message content') + end + end + end end context 'when smtp enabled for email channel' do diff --git a/spec/models/application_record_external_credentials_encryption_spec.rb b/spec/models/application_record_external_credentials_encryption_spec.rb new file mode 100644 index 000000000..65c347434 --- /dev/null +++ b/spec/models/application_record_external_credentials_encryption_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ApplicationRecord do + it_behaves_like 'encrypted external credential', + factory: :channel_email, + attribute: :smtp_password, + value: 'smtp-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_email, + attribute: :imap_password, + value: 'imap-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_twilio_sms, + attribute: :auth_token, + value: 'twilio-secret' + + it_behaves_like 'encrypted external credential', + factory: :integrations_hook, + attribute: :access_token, + value: 'hook-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_facebook_page, + attribute: :page_access_token, + value: 'fb-page-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_facebook_page, + attribute: :user_access_token, + value: 'fb-user-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_instagram, + attribute: :access_token, + value: 'ig-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_line, + attribute: :line_channel_secret, + value: 'line-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_line, + attribute: :line_channel_token, + value: 'line-token-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_telegram, + attribute: :bot_token, + value: 'telegram-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_twitter_profile, + attribute: :twitter_access_token, + value: 'twitter-access-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_twitter_profile, + attribute: :twitter_access_token_secret, + value: 'twitter-secret-secret' + + context 'when backfilling legacy plaintext' do + before do + skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured? + end + + it 'reads existing plaintext and encrypts on update' do + account = create(:account) + channel = create(:channel_email, account: account, smtp_password: nil) + + # Simulate legacy plaintext by updating the DB directly + sql = ActiveRecord::Base.send( + :sanitize_sql_array, + ['UPDATE channel_email SET smtp_password = ? WHERE id = ?', 'legacy-plain', channel.id] + ) + ActiveRecord::Base.connection.execute(sql) + + legacy_record = Channel::Email.find(channel.id) + expect(legacy_record.smtp_password).to eq('legacy-plain') + + legacy_record.update!(smtp_password: 'encrypted-now') + + stored_value = legacy_record.reload.read_attribute_before_type_cast(:smtp_password) + expect(stored_value).to be_present + expect(stored_value).not_to include('encrypted-now') + expect(legacy_record.smtp_password).to eq('encrypted-now') + end + end + + context 'when looking up telegram legacy records' do + before do + skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured? + end + + it 'finds plaintext records via fallback lookup' do + channel = create(:channel_telegram, bot_token: 'legacy-token') + + # Simulate legacy plaintext by updating the DB directly + sql = ActiveRecord::Base.send( + :sanitize_sql_array, + ['UPDATE channel_telegram SET bot_token = ? WHERE id = ?', 'legacy-token', channel.id] + ) + ActiveRecord::Base.connection.execute(sql) + + found = Channel::Telegram.find_by(bot_token: 'legacy-token') + expect(found).to eq(channel) + end + end +end diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index a0bd48e39..c5e080677 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -4,6 +4,12 @@ require 'rails_helper' require Rails.root.join 'spec/models/concerns/liquidable_shared.rb' RSpec.describe Message do + before do + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true) + # rubocop:enable RSpec/AnyInstance + end + context 'with validations' do it { is_expected.to validate_presence_of(:inbox_id) } it { is_expected.to validate_presence_of(:conversation_id) } @@ -678,4 +684,54 @@ RSpec.describe Message do end end end + + describe '#reindex_for_search callback' do + let(:account) { create(:account) } + let(:conversation) { create(:conversation, account: account) } + + before do + allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true) + account.enable_features('advanced_search_indexing') + end + + context 'when message should be indexed' do + it 'calls reindex_for_search for incoming message on create' do + message = build(:message, conversation: conversation, account: account, message_type: :incoming) + expect(message).to receive(:reindex_for_search) + message.save! + end + + it 'calls reindex_for_search for outgoing message on update' do + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true) + # rubocop:enable RSpec/AnyInstance + message = create(:message, conversation: conversation, account: account, message_type: :outgoing) + expect(message).to receive(:reindex_for_search).and_return(true) + message.update!(content: 'Updated content') + end + end + + context 'when message should not be indexed' do + it 'does not call reindex_for_search for activity message' do + message = build(:message, conversation: conversation, account: account, message_type: :activity) + expect(message).not_to receive(:reindex_for_search) + message.save! + end + + it 'does not call reindex_for_search for unpaid account on cloud' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + account.disable_features('advanced_search_indexing') + message = build(:message, conversation: conversation, account: account, message_type: :incoming) + expect(message).not_to receive(:reindex_for_search) + message.save! + end + + it 'does not call reindex_for_search when advanced search is not allowed' do + allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false) + message = build(:message, conversation: conversation, account: account, message_type: :incoming) + expect(message).not_to receive(:reindex_for_search) + message.save! + end + end + end end diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb index ede1ba824..6c23e9b71 100644 --- a/spec/services/whatsapp/incoming_message_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_service_spec.rb @@ -341,6 +341,58 @@ describe Whatsapp::IncomingMessageService do end end + describe 'When the incoming waid is an Argentine number with 9 after country code' do + let(:wa_id) { '5491123456789' } + + it 'creates appropriate conversations, message and contacts if contact does not exist' do + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(whatsapp_channel.inbox.messages.first.content).to eq('Test') + expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id) + end + + it 'appends to existing contact if contact inbox exists with normalized format' do + # Normalized format removes the 9 after country code + normalized_wa_id = '541123456789' + contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id) + last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + # no new conversation should be created + expect(whatsapp_channel.inbox.conversations.count).to eq(1) + # message appended to the last conversation + expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body]) + # should use the normalized wa_id from existing contact + expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(normalized_wa_id) + end + end + + describe 'When incoming waid is an Argentine number without 9 after country code' do + let(:wa_id) { '541123456789' } + + context 'when a contact inbox exists with the same format' do + it 'appends to existing contact' do + contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id) + last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + # no new conversation should be created + expect(whatsapp_channel.inbox.conversations.count).to eq(1) + # message appended to the last conversation + expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body]) + end + end + + context 'when a contact inbox does not exist' do + it 'creates contact inbox with the incoming waid' do + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(whatsapp_channel.inbox.messages.first.content).to eq('Test') + expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id) + end + end + end + describe 'when message processing is in progress' do it 'ignores the current message creation request' do params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }], diff --git a/spec/services/whatsapp/populate_template_parameters_service_spec.rb b/spec/services/whatsapp/populate_template_parameters_service_spec.rb new file mode 100644 index 000000000..05390bd90 --- /dev/null +++ b/spec/services/whatsapp/populate_template_parameters_service_spec.rb @@ -0,0 +1,70 @@ +require 'rails_helper' + +describe Whatsapp::PopulateTemplateParametersService do + let(:service) { described_class.new } + + describe '#normalize_url' do + it 'normalizes URLs with spaces' do + url_with_spaces = 'https://example.com/path with spaces' + normalized = service.send(:normalize_url, url_with_spaces) + + expect(normalized).to eq('https://example.com/path%20with%20spaces') + end + + it 'handles URLs with special characters' do + url = 'https://example.com/path?query=test value' + normalized = service.send(:normalize_url, url) + + expect(normalized).to include('https://example.com/path') + expect(normalized).not_to include(' ') + end + + it 'returns valid URLs unchanged' do + url = 'https://example.com/valid-path' + normalized = service.send(:normalize_url, url) + + expect(normalized).to eq(url) + end + end + + describe '#build_media_parameter' do + context 'when URL contains spaces' do + it 'normalizes the URL before building media parameter' do + url_with_spaces = 'https://example.com/image with spaces.jpg' + result = service.build_media_parameter(url_with_spaces, 'IMAGE') + + expect(result[:type]).to eq('image') + expect(result[:image][:link]).to eq('https://example.com/image%20with%20spaces.jpg') + end + end + + context 'when URL contains special characters in query string' do + it 'normalizes the URL correctly' do + url = 'https://example.com/video.mp4?title=My Video' + result = service.build_media_parameter(url, 'VIDEO', 'test_video') + + expect(result[:type]).to eq('video') + expect(result[:video][:link]).not_to include(' ') + end + end + + context 'when URL is already valid' do + it 'builds media parameter without changing URL' do + url = 'https://example.com/document.pdf' + result = service.build_media_parameter(url, 'DOCUMENT', 'test.pdf') + + expect(result[:type]).to eq('document') + expect(result[:document][:link]).to eq(url) + expect(result[:document][:filename]).to eq('test.pdf') + end + end + + context 'when URL is blank' do + it 'returns nil' do + result = service.build_media_parameter('', 'IMAGE') + + expect(result).to be_nil + end + end + end +end diff --git a/spec/support/examples/encrypted_external_credential_examples.rb b/spec/support/examples/encrypted_external_credential_examples.rb new file mode 100644 index 000000000..c67d814a9 --- /dev/null +++ b/spec/support/examples/encrypted_external_credential_examples.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'encrypted external credential' do |factory:, attribute:, value: 'secret-token'| + before do + skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured? + if defined?(Facebook::Messenger::Subscriptions) + allow(Facebook::Messenger::Subscriptions).to receive(:subscribe).and_return(true) + allow(Facebook::Messenger::Subscriptions).to receive(:unsubscribe).and_return(true) + end + end + + it "encrypts #{attribute} at rest" do + record = create(factory, attribute => value) + + raw_stored_value = record.reload.read_attribute_before_type_cast(attribute).to_s + expect(raw_stored_value).to be_present + expect(raw_stored_value).not_to include(value) + expect(record.public_send(attribute)).to eq(value) + expect(record.encrypted_attribute?(attribute)).to be(true) + end +end