diff --git a/Gemfile b/Gemfile index 7735dc099..fab536116 100644 --- a/Gemfile +++ b/Gemfile @@ -3,8 +3,10 @@ source 'https://rubygems.org' ruby '3.4.4' ##-- base gems for rails --## + gem 'rack-cors', '2.0.0', require: 'rack/cors' -gem 'rails', '~> 7.1' +gem 'rails', '7.2.3.1' + # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', require: false @@ -52,9 +54,7 @@ gem 'csv-safe' ##-- for active storage --## gem 'aws-sdk-s3', require: false -# original gem isn't maintained actively -# we wanted updated version of faraday which is a dependency for slack-ruby-client -gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false +gem 'azure-blob', require: false gem 'google-cloud-storage', '>= 1.48.0', require: false gem 'image_processing' @@ -83,7 +83,8 @@ gem 'barnes' ##--- gems for authentication & authorization ---## gem 'devise', '>= 4.9.4' -gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' +# 2.2.3 requires Devise 5; Chatwoot remains on Devise 4 for this upgrade stack. +gem 'devise-secure_password', '2.2.1' gem 'devise_token_auth', '>= 1.2.3' gem 'rails-i18n', '~> 7.0' # two-factor authentication @@ -95,7 +96,7 @@ gem 'pundit' # super admin gem 'administrate', '>= 0.20.1' gem 'administrate-field-active_storage', '>= 1.0.3' -gem 'administrate-field-belongs_to_search', '>= 0.9.0' +gem 'administrate-field-belongs_to_search', '>= 0.10.0' ##--- gems for pubsub service ---## # https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/ @@ -133,7 +134,7 @@ gem 'sentry-ruby', require: false gem 'sentry-sidekiq', '>= 5.19.0', require: false ##-- background job processing --## -gem 'sidekiq', '~> 7.3', '>= 7.3.1' +gem 'sidekiq', '~> 7.3.10' # We want cron jobs gem 'sidekiq-cron', '>= 2.4.0' # for sidekiq healthcheck diff --git a/Gemfile.lock b/Gemfile.lock index 33bfcf378..3b37ba82f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,110 +1,82 @@ -GIT - remote: https://github.com/chatwoot/azure-storage-ruby - revision: 9957cf899d33a285b5dfe15bdb875292398e392b - branch: chatwoot - specs: - azure-storage-blob (2.0.3) - azure-storage-common (~> 2.0) - nokogiri (~> 1, >= 1.10.8) - azure-storage-common (2.0.4) - faraday (~> 2.0) - faraday-follow_redirects (~> 0.3.0) - faraday-net_http_persistent (~> 2.0) - net-http-persistent (~> 4.0) - nokogiri (~> 1, >= 1.10.8) - -GIT - remote: https://github.com/chatwoot/devise-secure_password - revision: adcc85fe1babfe40feae73dbcae64d14fff86e69 - branch: chatwoot - specs: - devise-secure_password (2.0.1) - devise (>= 4.0.0, < 5.0.0) - railties (>= 5.0.0, < 8.0.0) - GEM remote: https://rubygems.org/ specs: - actioncable (7.1.5.2) - actionpack (= 7.1.5.2) - activesupport (= 7.1.5.2) + actioncable (7.2.3.1) + actionpack (= 7.2.3.1) + activesupport (= 7.2.3.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.5.2) - actionpack (= 7.1.5.2) - activejob (= 7.1.5.2) - activerecord (= 7.1.5.2) - activestorage (= 7.1.5.2) - activesupport (= 7.1.5.2) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.5.2) - actionpack (= 7.1.5.2) - actionview (= 7.1.5.2) - activejob (= 7.1.5.2) - activesupport (= 7.1.5.2) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (7.2.3.1) + actionpack (= 7.2.3.1) + activejob (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) + mail (>= 2.8.0) + actionmailer (7.2.3.1) + actionpack (= 7.2.3.1) + actionview (= 7.2.3.1) + activejob (= 7.2.3.1) + activesupport (= 7.2.3.1) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.5.2) - actionview (= 7.1.5.2) - activesupport (= 7.1.5.2) + actionpack (7.2.3.1) + actionview (= 7.2.3.1) + activesupport (= 7.2.3.1) + cgi nokogiri (>= 1.8.5) racc - rack (>= 2.2.4) + rack (>= 2.2.4, < 3.3) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.5.2) - actionpack (= 7.1.5.2) - activerecord (= 7.1.5.2) - activestorage (= 7.1.5.2) - activesupport (= 7.1.5.2) + useragent (~> 0.16) + actiontext (7.2.3.1) + actionpack (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.5.2) - activesupport (= 7.1.5.2) + actionview (7.2.3.1) + activesupport (= 7.2.3.1) builder (~> 3.1) + cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) active_record_query_trace (1.8) - activejob (7.1.5.2) - activesupport (= 7.1.5.2) + activejob (7.2.3.1) + activesupport (= 7.2.3.1) globalid (>= 0.3.6) - activemodel (7.1.5.2) - activesupport (= 7.1.5.2) - activerecord (7.1.5.2) - activemodel (= 7.1.5.2) - activesupport (= 7.1.5.2) + activemodel (7.2.3.1) + activesupport (= 7.2.3.1) + activerecord (7.2.3.1) + activemodel (= 7.2.3.1) + activesupport (= 7.2.3.1) timeout (>= 0.4.0) activerecord-import (2.1.0) activerecord (>= 4.2) - activestorage (7.1.5.2) - actionpack (= 7.1.5.2) - activejob (= 7.1.5.2) - activerecord (= 7.1.5.2) - activesupport (= 7.1.5.2) + activestorage (7.2.3.1) + actionpack (= 7.2.3.1) + activejob (= 7.2.3.1) + activerecord (= 7.2.3.1) + activesupport (= 7.2.3.1) marcel (~> 1.0) - activesupport (7.1.5.2) + activesupport (7.2.3.1) base64 benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) logger (>= 1.4.2) - minitest (>= 5.1) - mutex_m + minitest (>= 5.1, < 6) securerandom (>= 0.3) - tzinfo (~> 2.0) + tzinfo (~> 2.0, >= 2.0.5) acts-as-taggable-on (12.0.0) activerecord (>= 7.1, < 8.1) zeitwerk (>= 2.4, < 3.0) @@ -121,10 +93,10 @@ GEM administrate-field-active_storage (1.0.3) administrate (>= 0.2.2) rails (>= 7.0) - administrate-field-belongs_to_search (0.9.0) + administrate-field-belongs_to_search (0.10.0) administrate (>= 0.3, < 1.0) jbuilder (~> 2) - rails (>= 4.2, < 7.2) + rails (>= 4.2, < 8.0) selectize-rails (~> 0.6) ai-agents (0.12.0) ruby_llm (~> 1.14) @@ -164,12 +136,15 @@ GEM aws-sigv4 (~> 1.1) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) + azure-blob (0.8.0) + cgi + rexml barnes (0.0.9) multi_json (~> 1) statsd-ruby (~> 1.1) base64 (0.3.0) bcrypt (3.1.22) - benchmark (0.4.1) + benchmark (0.5.0) bigdecimal (4.1.2) bindex (0.8.1) bootsnap (1.16.0) @@ -187,14 +162,14 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) - cgi (0.5.1) + cgi (0.5.2) childprocess (5.1.0) logger (~> 1.5) cld3 (3.7.0) climate_control (1.2.0) coderay (1.1.3) - commonmarker (0.23.10) - concurrent-ruby (1.3.7) + commonmarker (0.23.12) + concurrent-ruby (1.3.8) connection_pool (2.5.5) crack (1.0.0) bigdecimal @@ -221,9 +196,9 @@ GEM msgpack datadog-ruby_core_source (3.5.3) date (3.5.1) - debug (1.8.0) - irb (>= 1.5.0) - reline (>= 0.3.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) declarative (0.0.20) devise (4.9.4) bcrypt (~> 3.0) @@ -231,6 +206,9 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) + devise-secure_password (2.2.1) + devise (>= 4.0.0, < 5.0.0) + railties (>= 5.0.0, < 9.0.0) devise-two-factor (6.1.0) activesupport (>= 7.0, < 8.1) devise (~> 4.0) @@ -291,7 +269,8 @@ GEM ruby2_keywords email-provider-info (0.0.1) email_reply_trimmer (0.1.13) - erubi (1.13.0) + erb (6.0.6) + erubi (1.13.1) et-orbi (1.2.11) tzinfo event_stream_parser (1.0.0) @@ -310,8 +289,6 @@ GEM faraday-net_http (>= 2.0, < 3.5) json logger - faraday-follow_redirects (0.3.0) - faraday (>= 1, < 3) faraday-mashify (1.0.0) faraday (~> 2.0) hashie @@ -319,9 +296,6 @@ GEM multipart-post (~> 2.0) faraday-net_http (3.4.4) net-http (~> 0.5) - faraday-net_http_persistent (2.1.0) - faraday (~> 2.5) - net-http-persistent (~> 4.0) faraday-retry (2.4.0) faraday (~> 2.0) faraday_middleware-aws-sigv4 (1.0.1) @@ -458,19 +432,22 @@ GEM mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.14.7) + i18n (1.15.2) concurrent-ruby (~> 1.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) - io-console (0.6.0) - irb (1.7.2) - reline (>= 0.3.6) + io-console (0.8.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) iso-639 (0.3.8) csv - jbuilder (2.11.5) - actionview (>= 5.0.0) - activesupport (>= 5.0.0) + jbuilder (2.15.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) jmespath (1.6.2) jquery-rails (4.6.0) rails-dom-testing (>= 1, < 3) @@ -570,7 +547,7 @@ GEM mini_magick (4.12.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.25.5) + minitest (5.27.0) mock_redis (0.36.0) ruby2_keywords msgpack (1.8.3) @@ -583,8 +560,6 @@ GEM activerecord (>= 5.2) net-http (0.9.1) uri (>= 0.11.1) - net-http-persistent (4.0.2) - connection_pool (~> 2.2) net-imap (0.6.4.1) date net-protocol @@ -687,7 +662,10 @@ GEM activerecord (>= 5.2) activesupport (>= 5.2) pgvector (0.1.1) - prism (1.4.0) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) procore-sift (1.0.0) activerecord (>= 6.1) pry (0.14.2) @@ -709,7 +687,7 @@ GEM rack (< 4) rack-cors (2.0.0) rack (>= 2.0.0) - rack-mini-profiler (3.2.0) + rack-mini-profiler (4.0.1) rack (>= 1.2.0) rack-protection (4.2.1) base64 (>= 0.1.0) @@ -725,21 +703,21 @@ GEM rack-timeout (0.6.3) rackup (2.2.1) rack (>= 3) - rails (7.1.5.2) - actioncable (= 7.1.5.2) - actionmailbox (= 7.1.5.2) - actionmailer (= 7.1.5.2) - actionpack (= 7.1.5.2) - actiontext (= 7.1.5.2) - actionview (= 7.1.5.2) - activejob (= 7.1.5.2) - activemodel (= 7.1.5.2) - activerecord (= 7.1.5.2) - activestorage (= 7.1.5.2) - activesupport (= 7.1.5.2) + rails (7.2.3.1) + actioncable (= 7.2.3.1) + actionmailbox (= 7.2.3.1) + actionmailer (= 7.2.3.1) + actionpack (= 7.2.3.1) + actiontext (= 7.2.3.1) + actionview (= 7.2.3.1) + activejob (= 7.2.3.1) + activemodel (= 7.2.3.1) + activerecord (= 7.2.3.1) + activestorage (= 7.2.3.1) + activesupport (= 7.2.3.1) bundler (>= 1.15.0) - railties (= 7.1.5.2) - rails-dom-testing (2.2.0) + railties (= 7.2.3.1) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) @@ -749,19 +727,30 @@ GEM rails-i18n (7.0.10) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.1.5.2) - actionpack (= 7.1.5.2) - activesupport (= 7.1.5.2) - irb + railties (7.2.3.1) + actionpack (= 7.2.3.1) + activesupport (= 7.2.3.1) + cgi + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) + rbs (4.0.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort redis (5.0.6) redis-client (>= 0.9.0) redis-client (0.26.4) @@ -769,7 +758,7 @@ GEM redis-namespace (1.10.0) redis (>= 4) regexp_parser (2.10.0) - reline (0.3.6) + reline (0.6.3) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) @@ -914,12 +903,12 @@ GEM zeitwerk (~> 2.5) shoulda-matchers (5.3.0) activesupport (>= 5.2.0) - sidekiq (7.3.1) - concurrent-ruby (< 2) - connection_pool (>= 2.3.0) + sidekiq (7.3.10) + base64 + connection_pool (>= 2.3.0, < 3) logger - rack (>= 2.2.4) - redis-client (>= 0.22.2) + rack (>= 2.2.4, < 3.3) + redis-client (>= 0.23.0, < 1) sidekiq-cron (2.4.0) cronex (>= 0.13.0) fugit (~> 1.8, >= 1.11.1) @@ -970,7 +959,8 @@ GEM statsd-ruby (1.5.0) stripe (18.0.1) telephone_number (1.4.20) - test-prof (1.2.1) + test-prof (1.6.3) + logger thor (1.4.0) tidewave (0.2.0) fast-mcp (~> 1.5.0) @@ -982,6 +972,7 @@ GEM i18n timeout (0.6.1) trailblazer-option (0.1.2) + tsort (0.2.0) twilio-ruby (7.6.0) faraday (>= 0.9, < 3.0) jwt (>= 1.5, < 3.0) @@ -1006,6 +997,7 @@ GEM uri (1.1.1) uri-idna (0.3.1) uri_template (0.7.0) + useragent (0.16.11) valid_email2 (5.2.6) activemodel (>= 3.2) mail (~> 2.5) @@ -1060,14 +1052,14 @@ DEPENDENCIES acts-as-taggable-on administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) - administrate-field-belongs_to_search (>= 0.9.0) + administrate-field-belongs_to_search (>= 0.10.0) ai-agents (>= 0.12.0) annotaterb attr_extras audited (~> 5.4, >= 5.4.1) aws-actionmailbox-ses (~> 0) aws-sdk-s3 - azure-storage-blob! + azure-blob barnes bootsnap brakeman @@ -1083,7 +1075,7 @@ DEPENDENCIES datadog (~> 2.0) debug (~> 1.8) devise (>= 4.9.4) - devise-secure_password! + devise-secure_password (= 2.2.1) devise-two-factor (>= 5.0.0) devise_token_auth (>= 1.2.3) dotenv-rails (>= 3.0.0) @@ -1152,7 +1144,7 @@ DEPENDENCIES rack-cors (= 2.0.0) rack-mini-profiler (>= 3.2.0) rack-timeout - rails (~> 7.1) + rails (= 7.2.3.1) rails-i18n (~> 7.0) redis redis-namespace @@ -1178,7 +1170,7 @@ DEPENDENCIES sentry-sidekiq (>= 5.19.0) shopify_api shoulda-matchers - sidekiq (~> 7.3, >= 7.3.1) + sidekiq (~> 7.3.10) sidekiq-cron (>= 2.4.0) sidekiq_alive simplecov (>= 0.21) diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index 8ae3109b9..93c05e1d3 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -29,7 +29,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Int end def create_issue - issue = linear_processor_service.create_issue(permitted_params, Current.user) + issue = linear_processor_service.create_issue(permitted_params.to_h.stringify_keys, Current.user) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else diff --git a/app/controllers/super_admin/instance_statuses_controller.rb b/app/controllers/super_admin/instance_statuses_controller.rb index b0e97b95d..3c6adc4c1 100644 --- a/app/controllers/super_admin/instance_statuses_controller.rb +++ b/app/controllers/super_admin/instance_statuses_controller.rb @@ -20,7 +20,9 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController end def instance_meta - @metrics['Database Migrations'] = ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed' + migrations_paths = ActiveRecord::Migrator.migrations_paths + migrations_context = ActiveRecord::MigrationContext.new(migrations_paths) + @metrics['Database Migrations'] = migrations_context.needs_migration? ? 'pending' : 'completed' end def chatwoot_version diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index 36515eb66..04af960c9 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -18,7 +18,7 @@ class Integrations::Hook < ApplicationRecord include Reauthorizable attr_readonly :app_id, :account_id, :inbox_id, :hook_type - before_validation :ensure_hook_type + before_validation :ensure_hook_type, on: :create after_create :trigger_setup_if_crm # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). @@ -96,7 +96,9 @@ class Integrations::Hook < ApplicationRecord end def ensure_hook_type - self.hook_type = app.params[:hook_type] if app.present? + return if app.blank? + + self.hook_type = app.params[:hook_type] end def validate_settings_json_schema diff --git a/app/models/message.rb b/app/models/message.rb index 1f60f4785..913fb5a64 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -453,6 +453,8 @@ class Message < ApplicationRecord end def reindex_for_search + return unless respond_to?(:reindex) + reindex(mode: :async) end end diff --git a/app/models/user.rb b/app/models/user.rb index 729f674d3..a5a9e331c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -69,7 +69,7 @@ class User < ApplicationRecord # TODO: remove in a future version once online status is moved to account users # remove the column availability from users - enum availability: { online: 0, offline: 1, busy: 2 } + enum :availability, { online: 0, offline: 1, busy: 2 } # The validation below has been commented out as it does not # work because :validatable in devise overrides this. @@ -78,7 +78,7 @@ class User < ApplicationRecord validates :name, presence: true validates :email, presence: true - serialize :otp_backup_codes, type: Array + serialize :otp_backup_codes, coder: YAML, type: Array # Encrypt sensitive MFA fields encrypts :otp_secret, deterministic: true @@ -89,7 +89,7 @@ class User < ApplicationRecord accepts_nested_attributes_for :account_users has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee - alias_attribute :conversations, :assigned_conversations + alias conversations assigned_conversations has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent has_many :reviewed_csat_survey_responses, foreign_key: 'review_notes_updated_by_id', class_name: 'CsatSurveyResponse', dependent: :nullify, inverse_of: :review_notes_updated_by diff --git a/app/services/crm/leadsquared/setup_service.rb b/app/services/crm/leadsquared/setup_service.rb index 0433f68fd..12117115b 100644 --- a/app/services/crm/leadsquared/setup_service.rb +++ b/app/services/crm/leadsquared/setup_service.rb @@ -82,7 +82,7 @@ class Crm::Leadsquared::SetupService end def update_hook_settings(params) - @hook.settings = @hook.settings.merge(params) + @hook.settings = @hook.settings.merge(params.stringify_keys) @hook.save! end diff --git a/config/storage.yml b/config/storage.yml index c01eb04d4..6421a2c5f 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -23,10 +23,11 @@ google: # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) microsoft: - service: AzureStorage + service: AzureBlob storage_account_name: <%= ENV.fetch('AZURE_STORAGE_ACCOUNT_NAME', '') %> storage_access_key: <%= ENV.fetch('AZURE_STORAGE_ACCESS_KEY', '') %> container: <%= ENV.fetch('AZURE_STORAGE_CONTAINER', '') %> + storage_blob_host: <%= ENV.fetch('AZURE_STORAGE_BLOB_HOST', nil) %> # s3 compatible service such as DigitalOcean Spaces, Minio. s3_compatible: diff --git a/db/migrate/20230515051424_update_article_image_keys.rb b/db/migrate/20230515051424_update_article_image_keys.rb index 3e8b9f77d..28f86d67a 100644 --- a/db/migrate/20230515051424_update_article_image_keys.rb +++ b/db/migrate/20230515051424_update_article_image_keys.rb @@ -12,8 +12,10 @@ class ArticleKeyConverter def convert_key(id) verifier_name = 'ActiveStorage' - key_generator = ActiveSupport::KeyGenerator.new(Rails.application.secrets.secret_key_base, iterations: 1000, - hash_digest_class: OpenSSL::Digest::SHA1) + secret_key_base = Rails.application.secret_key_base + key_generator = ActiveSupport::KeyGenerator.new(secret_key_base, + iterations: 1000, + hash_digest_class: OpenSSL::Digest::SHA1) key_generator = ActiveSupport::CachingKeyGenerator.new(key_generator) secret = key_generator.generate_key(verifier_name.to_s) verifier = ActiveSupport::MessageVerifier.new(secret) diff --git a/docs/rails_upgrade_assessment.md b/docs/rails_upgrade_assessment.md new file mode 100644 index 000000000..99259a1a7 --- /dev/null +++ b/docs/rails_upgrade_assessment.md @@ -0,0 +1,499 @@ +# Rails 8.1 Upgrade Assessment + +Date: 2026-07-24 +Repository revision assessed: `56e72eff8d67` + +## Executive conclusion + +Chatwoot should target **Rails 8.1.3**, but it should not jump from Rails 7.1 to 8.1 in one compatibility change or one production deployment. + +The implementation sequence is: + +1. Refresh the existing Rails upgrade PR on the latest `develop` and move to Rails 7.2.3.1 while retaining Rails 7.0 framework defaults. +2. Upgrade to Rails 8.0.5 in a stacked PR. +3. Upgrade to Rails 8.1.3 in a second stacked PR. +4. Enable the accumulated Rails 7.1, 7.2, 8.0, and 8.1 defaults in separately deployable risk groups. + +Rails itself recommends upgrading one minor version at a time because minor versions may change public APIs and each intermediate release provides the useful deprecation warnings. It also recommends enabling new framework defaults gradually. See the official [Rails upgrade guide](https://guides.rubyonrails.org/upgrading_ruby_on_rails.html). + +The distinction is: + +- **Direct-to-8.1 as the final target:** yes. +- **A direct 7.1-to-8.1 implementation and rollout:** no. +- **Stopping on Rails 7.2:** no. Rails 7.2.0 was released on 2024-08-09 and Rails security support lasts two years, so the 7.2 series reaches security end of life on 2026-08-09. It is a short-lived compatibility checkpoint, not a useful long-term destination. See the [Rails maintenance policy](https://guides.rubyonrails.org/maintenance_policy.html). + +As of this assessment, the current releases are Rails 7.1.6, 7.2.3.1, 8.0.5, and 8.1.3. See [Rails versions on RubyGems](https://rubygems.org/gems/rails/versions). + +Applying the published one-year bug-fix and two-year security windows to each series' initial release date gives: + +| Series | Role in this plan | Bug-fix support through | Security support through | +| --- | --- | --- | --- | +| Rails 7.2 | Short compatibility checkpoint | 2025-08-09, already ended | 2026-08-09 | +| Rails 8.0 | Short compatibility/rollback checkpoint | 2025-11-07, already ended | 2026-11-07 | +| Rails 8.1 | Target | 2026-10-22 | 2027-10-22 | + +## Current Chatwoot baseline + +| Area | Current state | Consequence | +| --- | --- | --- | +| Ruby | 3.4.4 | Already satisfies Rails 7.2 and Rails 8.x. Do not combine another Ruby upgrade with this work. | +| Rails | `~> 7.1`, locked at 7.1.5.2 | Four minor-version checkpoints remain: current 7.1 patch, 7.2, 8.0, and 8.1. | +| Framework defaults | `config.load_defaults 7.0` | The project is on Rails 7.1 code with Rails 7.0 behavior. Defaults migration is a separate body of work. | +| Initializers | 47 files | Several defaults and framework integrations are explicitly customized. | +| Application models | 109 files | Active Record changes have a broad possible blast radius. | +| Application jobs | 86 files | Active Job and transaction timing changes need representative integration tests. | +| Controllers | 185 files | Routing, redirects, query parsing, cookies, and response serialization need smoke coverage. | +| Specs | 796 total, including 234 Enterprise specs | The existing suite is substantial, but upgrade-specific contract tests are still needed. | +| Enterprise overlay | 427 files under `enterprise/app` | Eight of the known Rails 8 enum changes are in Enterprise code. | +| Asset stacks | Vite plus Sprockets-backed Super Admin assets | Rails 8 new-app defaults must not be applied mechanically. | +| File storage | Local/S3/GCS plus the Rails Azure service | Rails 8.1 removes the built-in Azure service. | + +Two defaults-sensitive surfaces are particularly important: + +- Chatwoot declares 19 encrypted attributes across 13 OSS and Enterprise files. +- Chatwoot has 166 `perform_later`/`deliver_later` call sites; 14 files contain both transaction and enqueue behavior. + +## What the compatibility experiments showed + +The assessment used isolated copies of this exact revision, changed only dependency constraints and the minimum code necessary to boot, and did not modify the application worktree. + +### Rails 7.2.3.1 + +- Bundler resolved Rails 7.2.3.1. +- The only definite application-code incompatibility found is `User` using `alias_attribute` to alias an association: + - `app/models/user.rb`: `alias_attribute :conversations, :assigned_conversations` + - Rails 7.2 treats `alias_attribute` as a database-attribute facility. This should become an explicit method or `alias_method`. +- `administrate-field-belongs_to_search` must move from 0.9.0 to 0.10.0 because 0.9.0 constrains Rails to `< 7.2`. +- Updating `jbuilder` and `debug` during this checkpoint removes failures that otherwise surface later: + - Jbuilder 2.11.5 uses `ActiveSupport::ProxyObject`, which Rails 8 removes. + - Debug 1.8.0 fails in the current Ruby 3.4 toolchain while running the Rails update task. +- The first live worker boot exposed an unsafe resolver result: Sidekiq 7.3.1 accepts `connection_pool >= 2.3`, so Bundler selected 3.0.2, but both Sidekiq scheduler threads then crashed because 7.3.1 calls the removed positional `TimedStack#pop` API. Sidekiq 7.3.10 explicitly constrains `connection_pool < 3`; that exact worker line and `connection_pool` 2.5.5 are required for this checkpoint. +- Rack Mini Profiler 3.2.0 references the removed `Rack::File` constant under Rack 3.2. Updating it to 4.0.1 restores its JavaScript endpoint. +- Rails' Active Storage install task tried to generate three migrations, but Chatwoot's initial schema migration and current `schema.rb` already contain `service_name`, variant records, and a nullable checksum. Those generated migrations must not be copied blindly. + +### Rails 8.1.3 + +The initial dependency resolution failed for three independent reasons: + +1. Chatwoot's `devise-secure_password` fork constrains Railties to `< 8`. +2. `administrate-field-belongs_to_search` 0.10.0 constrains Rails to `< 8` and Administrate to `< 1`. +3. `rails-i18n ~> 7.0` constrains Railties to `< 8`. + +After locally relaxing the two fork constraints, selecting Rails-compatible dependency versions, and making the minimum code changes: + +- `bin/rails zeitwerk:check` passed on Rails 8.1.3. +- This required changes to 35 existing main-repository files: + - `Gemfile` and `Gemfile.lock` + - 31 model files using removed enum keyword syntax + - the custom schema dumper initializer + - the singular `app` route +- The Rails 7.2 `User` association alias fix raises the known floor to **36 files**. +- This boot result does not prove production readiness. It deliberately did not solve Azure support, activate new defaults, run the complete test matrix, or add the required regression specs. + +The resolved Rails 8.1 graph also required or selected these application-level changes: + +| Dependency | Current | Resolved Rails 8.1-compatible line | Why it changes | +| --- | ---: | ---: | --- | +| `administrate` | 0.20.1 | 1.0.0 | 0.20.x requires Rails `< 8`; Super Admin compatibility and assets must be tested. | +| `administrate-field-belongs_to_search` | 0.9.0 | Custom/upstream-compatible release | 0.10.0 still requires Rails `< 8` and Administrate `< 1`. | +| `devise-secure_password` | Chatwoot fork | Upstream 2.2.1 | This is the latest upstream release supporting Devise 4 while allowing Railties 8; 2.2.3 requires Devise 5. | +| `rails-i18n` | 7.0.10 | 8.1.0 | The 7.x line requires Railties `< 8`. | +| `jbuilder` | 2.11.5 | 2.15.1 | Current version uses removed `ActiveSupport::ProxyObject`. | +| `acts-as-taggable-on` | 12.0.0 | 13.0.0 | Version 12 does not allow Active Record 8.1. | +| `audited` | 5.4.1 | 5.8.0 | Current constraint does not cover Rails 8. | +| `hairtrigger` | 1.0.0 | 1.3.1 | Current constraint does not cover Active Record 8. | +| `devise-two-factor` | 6.1.0 | 6.4.0 | Current constraint stops below Rails 8.1. | +| `devise_token_auth` | 1.2.5 | 1.2.6 | Current constraint stops below Rails 8.1. | +| `bullet` | 8.0.7 | 8.1.3 | Current version rejects Active Record 8.1 at runtime. | +| `sidekiq` | 7.3.1 | 7.3.10 with `connection_pool` 2.5.5 | Sidekiq 7.3.1 crashes its schedulers with `connection_pool` 3. Sidekiq 7.3.10 encodes the safe `< 3` constraint. Rails 8.1 also deprecates its built-in adapter in favor of Sidekiq's adapter, so avoid a simultaneous Sidekiq 8 migration. | +| `sprockets-rails` | Transitive | Explicit dependency, 3.5.x | Administrate 1 no longer supplies the transitive dependency, but Chatwoot Super Admin still uses Sprockets. | +| `debug` | 1.8.0 | 1.11.1 | Required for the current Ruby/Rails update tooling to run cleanly. | + +The current, Rails 7.2, and experimental Rails 8.1 lock graphs all passed `bundle-audit` against the advisory database updated on 2026-07-22. That does not replace a release-time audit. + +## File-impact estimate + +These estimates are for tracked files in the Chatwoot repository. Any remaining companion dependency work is listed separately. + +| Path | Exact known minimum | Production-ready estimate | Explanation | +| --- | ---: | ---: | --- | +| Rails 7.2.3.1, version compatibility only | 3 | 3–5 | `Gemfile`, `Gemfile.lock`, and the `User` association alias. | +| Refreshed existing Rails 7.2 PR | 22 | 22 | Also carries Rails-7.2 boundary fixes, Azure adapter replacement, Sidekiq scheduler and profiler compatibility, test stabilization, a production preflight, and the migration guide. | +| Rails 7.2.3.1 plus staged 7.1/7.2 defaults | 3 | 8–15 | Adds explicit defaults, encryption/serialization guards, and focused regression specs. | +| Rails 8.1.3, minimum boot floor from 7.1 | 36 | At least 36 | 31 enum files, two dependency files, `User`, routes, and schema dumper. | +| Rails 8.1.3, production-ready | 36 | 50–70 | Adds Azure strategy, Super Admin asset fixes, defaults, job/auth/storage/request specs, schema validation, and rollout configuration. | +| External companion repositories | 1 repository | Approximately 2–4 files | Only a fork/replacement of `administrate-field-belongs_to_search` remains; `devise-secure_password` is resolved upstream. | + +The sequential route does **not** materially reduce the final total number of changed files. It changes when and how those files are changed: + +- Rails 7.2 isolates the association and dependency compatibility issues. +- Rails 8.0 isolates removed APIs and the Administrate/Sprockets transition. +- Rails 8.1 isolates Azure removal and request/job/time behavior. +- Each checkpoint can be deployed and rolled back before a serializer or encryption default becomes irreversible. + +`bin/rails app:update --pretend` reported 26 config/bin/public actions for Rails 7.2 and 32 for Rails 8.1, in addition to attempted migrations. Those are candidate generator changes, not a recommended diff. Accepting them wholesale would overwrite or churn Chatwoot-specific Vite, Sprockets, environment, deployment, and initializer choices. + +## Changed interfaces and Chatwoot impact + +### Defaults Chatwoot has not yet activated + +Because `config.load_defaults` is still 7.0, upgrading the gem alone and activating defaults are different operations. + +| Interface/default | Change | Chatwoot exposure | Required action | +| --- | --- | --- | --- | +| Active Record encryption digest | Rails 7.1 changes key derivation/digest behavior and disables SHA-1 support for affected legacy ciphertext by default. | 19 encrypted declarations, including inbox/channel credentials, integration tokens, OTP secrets, webhook secrets, and Enterprise Twilio credentials. | Pin legacy-readable behavior first, inventory/decrypt all affected records, test deterministic lookups, rewrite ciphertext if needed, then flip the default in a later deploy. | +| Cache serialization | Rails 7.1 introduces format 7.1. | Redis cache and rolling application deploys. | Keep the old write format during the version deploy, then enable the new format after every old process is gone. Treat rollback compatibility as an acceptance criterion. | +| Message serialization/metadata | Rails 7.1 moves to newer serializers and optimized metadata. | Signed IDs, Active Storage IDs, OAuth account signed IDs, Warden/session-adjacent signatures, and any persisted messages. | Explicitly pin on the first deploy; validate old tokens and signatures; enable separately. | +| Callback ordering | Rails 7.1 changes callback execution defaults. | Message creation, notifications, webhooks, emails, imports, and auditing rely heavily on callbacks. | Add ordering assertions around side effects and enable separately from the version bump. | +| Attribute/association validation | Rails 7.1 changes readonly-attribute and belongs-to foreign-key validation defaults. | High model count and Enterprise extensions. | Run the complete model/request suite and inspect new validation errors before enabling. | +| Test exception handling | Rails 7.1 replaces boolean `show_exceptions` values with `:all`, `:rescuable`, and `:none`. | `config/environments/test.rb` still sets `true`. | Replace it with the intentional symbolic mode and re-run controller/request exception specs. | +| HTML parsing/sanitization | Rails 7.1 defaults use HTML5 variants. | Email/message rendering, Help Center content, and Action Text. | Run content fixtures through sanitizer and rendering regression tests. | +| Enqueue after transaction commit | Rails 7.2 changes the standard job/transaction integration. | 166 enqueue/delivery calls and 14 files combining transactions and enqueueing. | Verify jobs never observe uncommitted/missing data and that rollback does not enqueue side effects. | +| PostgreSQL date decoding and migration timestamps | Rails 7.2 changes decoding and validates migration timestamps. | Reporting, imports, schema setup, and old migration history. | Run reporting/import specs and validate both existing-database and fresh-database setup. | + +An AST scan found no explicit `return`, `break`, or `throw` inside Chatwoot transaction blocks, so Rails 7.2's changed non-local-return transaction behavior has no direct syntax hit in the current app. Keep a runtime audit for gem and dynamically composed behavior. + +### Rails 7.2 interface changes + +| Interface | Repo finding | Migration | +| --- | --- | --- | +| `alias_attribute` | `User` aliases `conversations` to an association. | Replace with an explicit delegating method or `alias_method`; add a user association spec. | +| Active Job test adapter selection | Tests now consistently respect configured `queue_adapter`. | Ensure test config uses the test adapter where matcher semantics require it; run job and mailer specs. | +| Framework defaults | Enqueue timing, Active Storage WebP behavior, PostgreSQL date decoding, migration timestamp validation, and YJIT defaults become available. | Activate one risk group at a time after the Rails 7.2 code deploy. | + +See the official [Rails 7.2 release notes](https://guides.rubyonrails.org/7_2_release_notes.html) and [upgrade guide](https://guides.rubyonrails.org/upgrading_ruby_on_rails.html). + +### Rails 8.0 interface changes + +| Interface | Repo finding | Migration | +| --- | --- | --- | +| Enum declaration | Rails 8 removes keyword-form enum declarations. Chatwoot has 39 declarations in 31 files: 23 OSS and 8 Enterprise files. | Change `enum status: ...` to `enum :status, ...`; preserve prefixes, suffixes, defaults, scopes, and serialized values; run model/API specs. | +| `ActiveSupport::ProxyObject` | Jbuilder 2.11.5 uses the removed class. | Upgrade Jbuilder before the Rails 8 bump. | +| Active Record internal APIs | Rails removes deprecated connection-pool and schema behavior. Chatwoot subclasses a generic internal schema dumper constant. | Rework or remove the schema dumper monkey patch against the PostgreSQL dumper and verify triggers/indexes/schema output. | +| Active Job transaction setting | Old `enqueue_after_transaction_commit` configuration is deprecated on the path to removal in 8.1. | Use the supported boolean/per-job behavior and verify Sidekiq jobs around commit and rollback. | +| Azure Active Storage | The service is deprecated in 8.0. | Complete an extracted/custom adapter or customer migration before 8.1. | +| Fresh database migration behavior | `db:migrate` on a fresh database loads the schema before pending migrations. | Test both fresh schema load and upgrade from the oldest supported production schema. | +| New-app stack defaults | Propshaft, Solid Queue, Solid Cache, Solid Cable, Kamal, and the authentication generator are promoted for new apps. | Do not adopt them as part of the Rails upgrade. Existing Vite, Sprockets, Sidekiq, Redis, and Devise contracts should remain stable. | + +See the official [Rails 8.0 release notes](https://guides.rubyonrails.org/8_0_release_notes.html). + +### Rails 8.1 interface changes + +| Interface | Repo finding | Migration | +| --- | --- | --- | +| Azure Active Storage service | Rails removes the built-in service. `config/storage.yml` still defines `service: AzureStorage`, selectable with `ACTIVE_STORAGE_SERVICE=microsoft`. | This is a hard production blocker for Azure-backed installations. Supply a maintained adapter with integration tests or formally migrate/deprecate Azure. | +| Routes | `resource :app, only: [:index]` fails Rails 8.1 route validation because a singular resource has no collection index action. | Express the route with an explicit `scope`/`get` while preserving every existing URL and helper consumed by the frontend. | +| Parameter parsing | Leading-bracket parameter names are no longer normalized and semicolons are no longer query separators. | Add request contract tests and sample production traffic for API, OAuth, and webhook query strings. Communicate any client-facing incompatibility. | +| Routes to multiple paths | Deprecated multi-path route support is removed. | Confirm all routes compile and compare `rails routes` before and after. | +| Time conversion | `to_time` now preserves the receiver timezone; Time/TimeWithZone arithmetic removals land. | Test the two application `to_time` call sites, especially reporting timezone boundaries and DST fixtures. | +| `schema.rb` order | Table columns are sorted alphabetically. | Expect a large one-file diff; isolate it in a schema-only commit and prove no semantic schema loss. | +| Active Job adapter | The Rails built-in Sidekiq adapter is deprecated in favor of the adapter shipped by Sidekiq. Symbol values and the application-level enqueue-after-commit setting are removed. | Update within Sidekiq 7.3.x, verify the loaded adapter origin, and test retries, schedules, unique/cron jobs, mail delivery, and transaction boundaries. | +| Custom job serializers | `#klass` must be public. | No custom serializer was found in the current tree; retain a boot-time registry check. | +| Redirect/JSON defaults | New 8.1 apps raise on path-relative redirects and stop escaping selected HTML characters in JSON. | When activating 8.1 defaults, audit 73 `redirect_to` call sites and API snapshots. Version-only boot can retain old behavior. | +| Order-dependent finders | Rails 8.1 deprecates order-dependent `first` calls without an order. | Capture deprecations in CI and fix only queries where nondeterminism matters. | +| `ActiveSupport::Configurable` | Deprecated in 8.1. | The experimental boot warning originates in `omniauth-rails_csrf_protection` 1.0.2; track or patch upstream before Rails 8.2. | + +See the official [Rails 8.1 release notes](https://guides.rubyonrails.org/8_1_release_notes.html). + +### Removed interfaces audited without a direct application hit + +The repository scan also checked the principal removed interfaces that do not currently require an application edit: + +| Removed/deprecated interface | Audit result | +| --- | --- | +| `ActiveRecord::ConnectionAdapters::ConnectionPool#connection` | No application or Enterprise call found. | +| Active Job `:never`, `:always`, and `:default` enqueue-after-commit values | No application configuration using these symbols found. | +| Application-level deprecated `config.active_job.enqueue_after_transaction_commit` | No current setting found; transaction/enqueue behavior still needs runtime tests. | +| Custom Active Job serializer private `#klass` | No custom Active Job serializer found. | +| SuckerPunch's internal Rails adapter | SuckerPunch is not the configured queue backend. | +| Time-to-TimeWithZone addition and `Time#since(Time)` | No direct application call pattern found; the two `to_time` call sites remain in scope. | +| SQLite adapter removals | Chatwoot's supported production path is PostgreSQL; fresh and upgraded PostgreSQL remain in the matrix. | + +## Highest-risk break scenarios + +| Risk | Severity | What could break | Detection and containment | +| --- | --- | --- | --- | +| Encrypted credentials become unreadable | Critical | Email inbox passwords, social tokens, webhook secrets, OTP secrets, integration tokens, and Enterprise channel credentials. | Preflight decrypt scan with counts by model/attribute; dual-readable configuration; canary reads and writes; do not remove legacy support until all old processes and ciphertext are gone. | +| Azure file storage stops booting or serving files | Critical for affected installs | Upload, download, preview, direct upload, and purge operations when `ACTIVE_STORAGE_SERVICE=microsoft`. | Decide support policy before Rails 8.0; run Azure emulator/real-account contract tests; block Rails 8.1 rollout without a passing adapter. | +| Jobs run before commit or disappear on rollback | High | Notifications, email, webhooks, message fan-out, imports, search indexing, and reporting side effects. | Transaction integration specs plus Sidekiq smoke tests; compare enqueue and failure metrics during canary. | +| Authentication/session/signature invalidation | High | Agent sessions, API auth, OTP flows, signed IDs, password policy, OAuth, and Active Storage links. | Mixed-version rolling-deploy test; old cookie/token fixtures; fork compatibility specs; explicit serializer settings. | +| Super Admin loses CSS/JS or fields | High | Administrate 1 changes and loss of transitive Sprockets dependencies. | Make `sprockets-rails` explicit or migrate intentionally; production asset precompile; browser smoke every Super Admin CRUD path and custom field. | +| API or webhook parameter behavior changes | High | Leading-bracket or semicolon query strings from old/third-party clients. | Production log sampling, request fixtures, contract tests, and a documented client migration if such traffic exists. | +| Enum API/values drift | High | Scopes, predicates, validations, JSON values, database values, Enterprise policies, and background jobs. | Mechanical syntax-only commits split by domain; assert mappings before/after; run affected request/job specs. | +| Schema or fresh install drifts | High | Trigger definitions, schema loader, Active Storage duplicate migrations, and extension setup. | Compare schema objects, indexes, constraints, triggers, and extensions on upgraded and fresh PostgreSQL databases. | +| Time/reporting results shift | Medium | Daily boundaries, unread counts, report grouping, and DST behavior. | Timezone matrix tests for UTC, non-UTC, and DST transition dates. | +| Hidden Enterprise incompatibility | Medium to high | Overrides and prepended modules load after the OSS path appears healthy. | Run `zeitwerk:check`, the full Enterprise suite, and product smokes with Enterprise enabled at every checkpoint. | + +## Prerequisites and migration decisions + +The following work must happen before Rails 8.1 can be considered releasable: + +1. **Choose the Azure policy.** + - Preserve Azure by extracting/maintaining an `ActiveStorage::Service` adapter, or + - announce deprecation and supply a verified blob migration path to S3/GCS. + - This decision changes product support and must not be inferred during implementation. + +2. **Prepare the Rails-constrained Admin dependency.** + - `devise-secure_password` is resolved by moving to upstream 2.2.1 in the Rails 7.2 checkpoint. + - Replace, upstream, or maintain a Rails 8/Administrate 1-compatible `administrate-field-belongs_to_search`. + +3. **Inventory encrypted production data.** + - Count rows by model and encrypted attribute. + - Prove all values decrypt under the transition configuration. + - Prove deterministic queries still return the same records. + - Define the re-encryption batch, retry, audit, and rollback procedure. + +4. **Define rolling-deploy compatibility.** + - Pin cache, message, cookie, and encryption formats during version-only deploys. + - Test old and new application processes against the same Redis, database, jobs, and cookies. + - Enable each new write format only after old processes cannot return. + +5. **Make the Super Admin asset ownership explicit.** + - Either declare and retain Sprockets or migrate those assets in a separate project. + - Do not let an Administrate dependency update silently choose the asset architecture. + +6. **Reconcile Active Storage migration history.** + - Verify every supported installation has the `service_name`, variant record, and nullable checksum changes even though the squashed initial migration already has them. + - Add idempotent corrective migrations only if real supported installations are missing them. + +7. **Add upgrade observability.** + - Fail CI on new Rails deprecations after an allowlist is baselined. + - Add dashboards/alerts for job failures and latency, session/auth failures, Active Storage errors, request 4xx/5xx, database errors, and cache hit rate. + +## Dependency-ordered implementation plan + +Each milestone should be independently green and revertible. Framework defaults and serialized write formats should not change in the same deployment as the Rails gem version. + +### Milestone 0: Guardrails and dependency unblockers + +#### R0.1 — Establish the upgrade CI lane (S) + +- **Files:** CI configuration, deprecation configuration, and a small boot/contract spec. +- **Work:** Run the current suite with deprecations captured; add `zeitwerk:check`, production boot, assets, fresh schema load, and `bundle-audit`. +- **Acceptance:** Current Rails 7.1.5.2 is green; known deprecations are recorded with owners; newly introduced deprecations fail the upgrade lane. +- **Verify:** Full OSS and Enterprise RSpec suites, targeted frontend build, RuboCop for changed Ruby files, production asset precompile, and fresh PostgreSQL schema load. +- **Depends on:** None. + +#### R0.2 — Move `devise-secure_password` to upstream 2.2.1 (S) + +- **Work:** Replace the inaccessible Chatwoot fork with the exact upstream release supporting Devise 4 and Railties 8; validate password policy, Devise callbacks, authentication, and MFA. +- **Acceptance:** Upstream 2.2.1 is locked and the authentication/MFA matrix passes on every checkpoint. +- **Depends on:** R0.1. + +#### R0.3 — Resolve the Administrate search field dependency (M, companion repository) + +- **Work:** Upstream, fork, or replace the field for Administrate 1/Rails 8; verify its search query, form, display, and authorization behavior. +- **Acceptance:** Versioned dependency compatible with both the Rails 7.2 checkpoint and Administrate 1; Super Admin contract tests pass. +- **Depends on:** R0.1. + +#### R0.4 — Decide and prototype Azure support (M) + +- **Work:** Select preserved-adapter or migration/deprecation path; prototype the chosen option against upload/download/direct-upload/variant/purge operations. +- **Acceptance:** Written product decision and a passing proof of concept. Rails 8.1 is blocked until this is complete. +- **Depends on:** None. + +### Milestone 1: Reach Rails 7.2 safely + +#### R1.1 — Resolve Rails 7.2 dependencies and association alias (M) + +- **Files:** `Gemfile`, `Gemfile.lock`, `app/models/user.rb`, focused dependency/user specs. +- **Work:** Upgrade Rails to 7.2.3.1, select `administrate-field-belongs_to_search` 0.10.0, update Jbuilder/debug, and replace the association `alias_attribute`. +- **Acceptance:** Rails reports 7.2.3.1; defaults remain 7.0; user conversation APIs are unchanged; no unexpected lockfile upgrades. +- **Verify:** User model/request specs, authentication specs, Admin specs, full suite, `zeitwerk:check`, asset builds, and advisory audit. +- **Depends on:** R0.1 and a compatible result from R0.3. + +#### R1.2 — Validate data, jobs, and supported databases on 7.2 (M) + +- **Files:** Focused request/job/storage/reporting specs and CI matrix only. +- **Work:** Test Active Job adapter behavior, transaction boundaries, PostgreSQL date handling, migration timestamp validation, WebP variants, upgraded database, and fresh database. +- **Acceptance:** No job observes uncommitted data; rollback suppresses side effects; schema and reports match the 7.1 baseline. +- **Depends on:** R1.1. + +**Deployment checkpoint:** Deploy Rails 7.2.3.1 with 7.0 defaults, canary it, and prove rollback to 7.1.6. + +### Milestone 2: Catch up Rails 7.1 and 7.2 defaults + +#### R2.1 — Migrate encryption and serialized formats (M) + +- **Files:** Framework-default initializer/config, encryption service or runner if needed, and encryption/cache/signature specs. +- **Work:** Pin transition settings; run decrypt/deterministic-query inventory; re-encrypt if required; then enable cache and message formats in separate deploys. +- **Acceptance:** Old ciphertext, cookies, signed IDs, cached values, and jobs remain readable through the rolling-deploy window; rollback procedure is exercised. +- **Depends on:** R1.2. + +#### R2.2 — Enable model, callback, parser, and job defaults (M) + +- **Files:** Defaults initializer plus focused model, callback, message-rendering, and job specs. +- **Work:** Enable remaining 7.1 defaults in small groups, then 7.2 defaults; keep any intentionally retained old setting documented. +- **Acceptance:** `config.load_defaults 7.2` is possible without an unexplained compatibility override; all product contracts remain stable. +- **Depends on:** R2.1. + +**Deployment checkpoint:** Run Rails 7.2 with 7.2 defaults before beginning Rails 8 removal work. + +### Milestone 3: Remove Rails 8.0 incompatibilities + +The enum rewrite should be split into four syntax-only tasks so review can verify value mappings: + +#### R3.1a — Conversation and messaging enums (S) + +- **Files:** `conversation`, `message`, `attachment`, `contact`, `inbox`, `webhook`, `notification`, and `notification_subscription`. +- **Acceptance:** Every mapping and generated predicate/scope is identical before and after. +- **Depends on:** R2.2. + +#### R3.1b — Accounts, channels, bots, and integrations enums (S) + +- **Files:** `user`, `account_user`, `agent_bot`, `agent_bot_inbox`, `assignment_policy`, `integrations/hook`, `channel/twilio_sms`, and `channel/web_widget`. +- **Acceptance:** Same as R3.1a, including deterministic encrypted integration behavior. +- **Depends on:** R2.2. + +#### R3.1c — Content, import, campaign, and filter enums (S) + +- **Files:** `article`, `data_import`, `data_import_item`, `custom_filter`, `custom_attribute_definition`, `campaign`, and `macro`. +- **Acceptance:** Same as R3.1a; import and campaign jobs preserve values and scopes. +- **Depends on:** R2.2. + +#### R3.1d — Enterprise enums (S) + +- **Files:** Eight Enterprise model/concern files found by the audit. +- **Acceptance:** Enterprise mappings, policies, Captain flows, SLA behavior, and Copilot message behavior are unchanged. +- **Depends on:** R2.2. + +#### R3.2 — Upgrade Administrate and own the asset pipeline (M) + +- **Files:** Dependency files, asset manifest/config if needed, Super Admin views/fields only where Administrate 1 requires it, and Admin specs. +- **Work:** Upgrade Administrate to 1.0, make `sprockets-rails` explicit, integrate R0.3, and preserve Vite for the dashboard. +- **Acceptance:** Production assets precompile; every Super Admin CRUD screen loads CSS/JS and custom fields; no dashboard asset regression. +- **Depends on:** R0.3, R3.1 tasks. + +#### R3.3 — Replace removed/internal Rails APIs (S) + +- **Files:** Schema dumper initializer, route compatibility only if needed on 8.0, and focused schema/route specs. +- **Work:** Remove or rebase the PostgreSQL schema dumper patch; upgrade Jbuilder; prove there are no remaining removed API calls. +- **Acceptance:** `zeitwerk:check`, schema dump/load, and route compilation pass on both Rails 7.2 and 8.0. +- **Depends on:** R3.1 tasks. + +#### R3.4 — Upgrade to Rails 8.0.5 (M) + +- **Files:** Dependency files plus narrowly required config/spec updates. +- **Work:** Update Rails, Administrate-related dependencies, Audited, Hairtrigger, Rails I18n, and compatible supporting gems. Keep Sidekiq 7.3 and existing runtime architecture. +- **Acceptance:** Rails 8.0.5 boots in production mode; 7.2 defaults remain explicit; OSS/Enterprise suites and product smokes pass. +- **Depends on:** R0.2, R3.2, R3.3. + +**Deployment checkpoint:** Deploy Rails 8.0.5 and observe it before removing Azure or activating Rails 8 defaults. + +### Milestone 4: Reach Rails 8.1 + +#### R4.1 — Complete the Azure storage migration/adapter (M) + +- **Files:** Adapter/config/migration runner and storage contract specs, according to R0.4. +- **Acceptance:** Every supported storage service passes upload, download, direct upload, variant, mirror if applicable, delete, and purge tests; customer rollback/migration procedure is documented. +- **Depends on:** R0.4, R3.4. + +#### R4.2 — Migrate Rails 8.1 routes and request contracts (M) + +- **Files:** `config/routes.rb` plus route/request/webhook/OAuth specs. +- **Work:** Replace the singular-resource index route, compare route tables, and test legacy query syntax exposure. +- **Acceptance:** Existing frontend URLs and helpers are unchanged; any unsupported third-party query syntax has an explicit migration decision. +- **Depends on:** R3.4. + +#### R4.3 — Update Rails 8.1 dependencies and job adapter (M) + +- **Files:** Dependency files, job config, and focused auth/job specs. +- **Work:** Update Rails I18n, acts-as-taggable-on, Devise Two Factor, Devise Token Auth, Bullet, and Sidekiq within 7.3.x; verify Sidekiq supplies the adapter. +- **Acceptance:** Authentication, OTP, tokens, tags, retries, cron, mail, and transaction timing pass; no Rails built-in Sidekiq adapter warning. +- **Depends on:** R0.2, R3.4. + +#### R4.4 — Upgrade to Rails 8.1.3 (M) + +- **Files:** Dependency files, time call sites if behavior requires an explicit conversion, schema dumper, and focused specs. +- **Work:** Take Rails 8.1.3, verify time semantics, allow the intentional schema sort in an isolated commit, and resolve app-owned deprecations. +- **Acceptance:** Production boot, `zeitwerk:check`, full OSS/Enterprise suites, asset builds, fresh/upgrade database paths, all storage services, auth, jobs, Action Cable, mailboxes, APIs, webhooks, and reporting smokes pass. +- **Depends on:** R4.1, R4.2, R4.3. + +**Deployment checkpoint:** Canary Rails 8.1.3 with prior defaults and serialized formats. Prove rollback to Rails 8.0.5. + +### Milestone 5: Activate Rails 8 defaults and close the migration + +#### R5.1 — Enable Rails 8.0 defaults (S) + +- **Work:** Enable each 8.0 default, including regex timeout and freshness behavior, with targeted tests. +- **Acceptance:** `config.load_defaults 8.0` is possible with documented exceptions only. +- **Depends on:** Stable R4.4 production checkpoint. + +#### R5.2 — Enable Rails 8.1 defaults (M) + +- **Work:** Enable relative-redirect enforcement, JSON escaping changes, view tracker changes, required-column ordering checks, and remaining defaults in separate risk groups. +- **Acceptance:** API snapshots, redirects, views, forms, and query behavior are intentionally accepted; `config.load_defaults 8.1` is active. +- **Depends on:** R5.1. + +#### R5.3 — Remove transition compatibility and finish observability window (S) + +- **Work:** Remove encryption/serializer dual-read settings only after the rollback window expires; close the deprecation allowlist; capture operational results. +- **Acceptance:** No unexplained Rails deprecations; no old processes/data require transition settings; runbook and support notes are complete. +- **Depends on:** Stable R5.2 deployment. + +## Verification matrix + +Every Rails-version checkpoint should run: + +```sh +eval "$(rbenv init -)" +bundle check +bundle exec rails runner 'puts Rails.version' +bundle exec rails zeitwerk:check +bundle exec bundle-audit check --update +bundle exec rspec +bundle exec rubocop +pnpm build +``` + +It should also run production-mode boot and asset precompile using CI-safe secrets, plus: + +- Fresh PostgreSQL database creation from `schema.rb`. +- Upgrade of a scrubbed production-like database with realistic encrypted data. +- Redis shared between old/new app and Sidekiq processes. +- Mixed-version rolling deploy and rollback. +- Disk, S3, GCS, and Azure-or-replacement Active Storage contracts. +- Agent login, API token, OTP, password policy/history, OAuth, and signed-link fixtures. +- Inbox message creation through API, email, supported social channels, and webhook delivery. +- Sidekiq immediate, scheduled, retry, cron, mail, and transaction-boundary jobs. +- Action Cable/WebSocket updates and notification fan-out. +- Super Admin CRUD and custom field browser smoke tests. +- Reporting across UTC, a non-UTC zone, and a DST transition. +- Enterprise-enabled suite and Captain/SLA/Copilot smokes. + +## Rollout and rollback rules + +- Never combine a Rails minor-version change with a new cache/message/encryption write format. +- Keep database changes expand/contract compatible with the previous deployed Rails version. +- Do not remove old serializer/encryption readers until all old workers, web processes, cached values, queued jobs, and rollback windows are gone. +- Canary web and worker processes separately; a healthy web boot does not prove Active Job compatibility. +- Preserve Rails 8.0.5 as the immediate rollback target for the Rails 8.1 deployment. +- Treat Azure service errors, decrypt failures, auth failure-rate increases, Sidekiq retry growth, and schema drift as rollout blockers rather than post-release cleanup. + +## Scope deliberately excluded + +The Rails migration should not also introduce: + +- Propshaft migration for the dashboard or Super Admin. +- Solid Queue, Solid Cache, or Solid Cable. +- Sidekiq 8. +- A different Ruby version. +- Authentication architecture replacement. +- Redis topology changes. +- Unrelated Rails generator changes. + +Each may be valuable later, but coupling them to the framework upgrade removes the ability to attribute and safely roll back failures. + +## Final recommendation + +Start the work now with Rails 8.1.3 as the declared target and Rails 7.2.3.1/8.0.5 as required deployable checkpoints. Rails 7.2 is too close to security end of life to justify a separate long-term upgrade project, but skipping it would discard the exact deprecation and isolation boundary Rails provides. + +The critical path is not the 31 mechanical enum edits. It is: + +1. Azure Active Storage support. +2. The Rails-constrained Administrate search-field dependency. +3. Encrypted-data and serialized-format compatibility across rolling deploys. +4. Administrate 1 plus explicit Sprockets ownership. +5. Job timing and Sidekiq adapter verification. + +Once those are settled, the remaining Rails 8 code changes are bounded and the isolated Rails 8.1 boot proves the application can reach the target without a broad rewrite. diff --git a/docs/rails_upgrades/7_2.md b/docs/rails_upgrades/7_2.md new file mode 100644 index 000000000..2f6971dc2 --- /dev/null +++ b/docs/rails_upgrades/7_2.md @@ -0,0 +1,97 @@ +# Production migration guide: Rails 7.2.3.1 + +This checkpoint upgrades Chatwoot from Rails 7.1.5.2 to Rails 7.2.3.1. It deliberately keeps `config.load_defaults 7.0`, so it does not change the Rails 7.1 or 7.2 serialization, encryption, callback, job-enqueue, Active Storage WebP, or PostgreSQL date-decoding defaults. + +There is no database schema migration in this checkpoint. + +## Application-level changes + +- The `User#conversations` association alias uses a Ruby method alias because Rails 7.2 reserves `alias_attribute` for database attributes. +- Migration status uses `ActiveRecord::MigrationContext`, rather than the removed connection-level helper. +- Strong parameters passed to Linear and LeadSquared are normalized to string-keyed hashes at their service boundaries. +- Integration hook type assignment runs only at creation, preserving Rails 7.2 readonly-attribute behavior. +- Existing encrypted MFA backup codes remain YAML-serialized. This release does not rewrite their persisted format. +- `devise-secure_password` moves from the Chatwoot fork to upstream 2.2.1, the latest release that supports both Devise 4 and Rails 8. Later upstream releases require Devise 5. +- Jbuilder, Debug, TestProf, and the Administrate belongs-to search field move to Rails-7.2-compatible versions. +- Sidekiq moves to 7.3.10, which constrains `connection_pool` below 3. Sidekiq 7.3.1 starts workers with `connection_pool` 3 but crashes both scheduler threads, so this pairing is a deployment requirement rather than an optional maintenance update. +- Rack Mini Profiler moves to 4.0.1 so development and staging profiler assets work with Rack 3.2. +- Azure Active Storage moves from the abandoned `azure-storage-blob` client to the `azure-blob` adapter. The configured service name remains `microsoft`, so existing `active_storage_blobs.service_name` values and Azure object keys do not change. + +## Before deployment + +1. Take the normal PostgreSQL and Redis backups and verify the database backup can be restored. +2. Use the same Ruby 3.4.4 and Node 24 versions as the release build. +3. Install dependencies and build assets. +4. Run the read-only preflight against a production replica or during the maintenance window: + + ```sh + EXPECTED_RAILS_VERSION=7.2.3.1 \ + EXPECTED_CONFIG_DEFAULTS=7.0 \ + EXPECTED_SIDEKIQ_VERSION=7.3.10 \ + EXPECTED_AZURE_SERVICE=AzureBlob \ + bundle exec rails runner script/rails_upgrade/preflight.rb + ``` + +5. If the installation has a large number of encrypted rows, a bounded rehearsal can use `PREFLIGHT_MAX_ENCRYPTED_ROWS=1000`. The final production preflight should omit that limit so every encrypted value is read. +6. If Azure is used, select a representative blob and verify that the new adapter can find it: + + ```sh + ACTIVE_STORAGE_CHECK_BLOB_ID= \ + EXPECTED_RAILS_VERSION=7.2.3.1 \ + EXPECTED_CONFIG_DEFAULTS=7.0 \ + EXPECTED_AZURE_SERVICE=AzureBlob \ + bundle exec rails runner script/rails_upgrade/preflight.rb + ``` + +7. Run the explicit write/read/delete smoke against every configured storage service. This creates one uniquely named text blob and purges it in an `ensure` block: + + ```sh + RAILS_UPGRADE_ALLOW_STORAGE_WRITE=true \ + RAILS_UPGRADE_STORAGE_SERVICE=microsoft \ + bundle exec rails runner script/rails_upgrade/active_storage_smoke.rb + ``` + +## Azure configuration + +Existing shared-key installations keep the same environment variables: + +- `AZURE_STORAGE_ACCOUNT_NAME` +- `AZURE_STORAGE_ACCESS_KEY` +- `AZURE_STORAGE_CONTAINER` + +The service key selected by `ACTIVE_STORAGE_SERVICE` remains `microsoft`. Only the adapter class in `config/storage.yml` changes from `AzureStorage` to `AzureBlob`; no blob copy or database update is required. + +For an Azurite rehearsal, set `AZURE_STORAGE_BLOB_HOST` to the emulator endpoint and use its account name, access key, and pre-created container. + +## Deployment order + +1. Build the image and precompile assets before replacing any process. +2. Run `bundle exec rails db:abort_if_pending_migrations`. +3. Start one web canary and verify health, login, API authentication, Super Admin, message creation, attachments, and Action Cable. +4. Replace the remaining web processes. +5. Start one Sidekiq canary and verify immediate, scheduled, mail, webhook, and retry queues. +6. Confirm startup logs show Sidekiq 7.3.10, every configured cron job is registered, and no scheduler thread reports a `ConnectionPool::TimedStack#pop` argument error. Wait for at least one minute-level cron job to enqueue and complete. +7. Replace the remaining workers and scheduler. +8. Run the full preflight again against the primary database. + +Web and worker processes from the old and new release may overlap because this checkpoint retains Rails 7.0 framework defaults and does not introduce a new persisted serialization format. + +## Post-deployment checks + +- No increase in decrypt errors, login failures, HTTP 5xx responses, Sidekiq retries, or dead jobs. +- Both Sidekiq scheduler threads remain alive and cron/scheduled queues continue to receive work. +- New and existing attachments download successfully from every enabled storage backend. +- Email ingestion and delivery, webhooks, scheduled jobs, and cron jobs continue to run. +- Super Admin assets and custom Administrate fields render correctly. +- `Rails.version` reports `7.2.3.1` and the preflight reports `rails.framework_defaults` as `7.0`. + +## Rollback + +Rollback is code-only because this checkpoint has no schema or data migration and keeps the old framework defaults. + +1. Restore the previous Rails 7.1 release for web and workers. +2. Restore the previous `AzureStorage` adapter configuration if Azure is enabled. +3. Re-run attachment reads, login, and a representative job. +4. Keep the database and Redis data created during the canary; their formats remain compatible with the prior release. + +Do not activate Rails 7.1/7.2 defaults in the same rollout. Encryption digest, cache/message serialization, callback ordering, job enqueue timing, WebP handling, and PostgreSQL date decoding require separately deployable compatibility work. diff --git a/lib/seeders/reports/message_creator.rb b/lib/seeders/reports/message_creator.rb index fc10716d9..cf3e826ab 100644 --- a/lib/seeders/reports/message_creator.rb +++ b/lib/seeders/reports/message_creator.rb @@ -15,7 +15,7 @@ class Seeders::Reports::MessageCreator end def create_messages - message_count = rand(MESSAGES_PER_CONVERSATION..MESSAGES_PER_CONVERSATION + 5) + message_count = rand(MESSAGES_PER_CONVERSATION..(MESSAGES_PER_CONVERSATION + 5)) first_agent_reply = true message_count.times do |i| diff --git a/script/rails_upgrade/active_storage_smoke.rb b/script/rails_upgrade/active_storage_smoke.rb new file mode 100644 index 000000000..1c9bcc1e0 --- /dev/null +++ b/script/rails_upgrade/active_storage_smoke.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'json' +require 'stringio' + +unless ENV['RAILS_UPGRADE_ALLOW_STORAGE_WRITE'] == 'true' + warn({ status: 'REFUSED', reason: 'Set RAILS_UPGRADE_ALLOW_STORAGE_WRITE=true to create and purge one test blob.' }.to_json) + exit 1 +end + +service_name = ENV.fetch('RAILS_UPGRADE_STORAGE_SERVICE') +payload = "chatwoot-rails-upgrade-storage-smoke-#{SecureRandom.hex(16)}" +blob = nil + +begin + blob = ActiveStorage::Blob.create_and_upload!( + io: StringIO.new(payload), + filename: "rails-upgrade-smoke-#{SecureRandom.hex(8)}.txt", + content_type: 'text/plain', + service_name: service_name + ) + + raise 'Uploaded blob does not exist in the configured service' unless blob.service.exist?(blob.key) + raise 'Downloaded blob did not match the uploaded payload' unless blob.download == payload + + puts({ status: 'PASS', service_name: service_name, service_class: blob.service.class.name }.to_json) +ensure + blob&.purge +end diff --git a/script/rails_upgrade/preflight.rb b/script/rails_upgrade/preflight.rb new file mode 100644 index 000000000..eb44001d5 --- /dev/null +++ b/script/rails_upgrade/preflight.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'json' + +module RailsUpgrade +end + +class RailsUpgrade::Preflight + def initialize + @failures = 0 + end + + def run + Rails.application.eager_load! + + check_version + check_framework_defaults + check_background_job_dependencies + check_migrations + check_required_columns + check_installation_configs + check_encrypted_attributes + check_active_storage + check_selected_blob + report('summary', @failures.zero?, failures: @failures) + + exit 1 unless @failures.zero? + end + + private + + def check_version + expected = ENV.fetch('EXPECTED_RAILS_VERSION') + check('rails.version', expected: expected, actual: Rails.version) { Rails.version == expected } + end + + def check_framework_defaults + expected = ENV.fetch('EXPECTED_CONFIG_DEFAULTS', '7.0') + actual = Rails.application.config.loaded_config_version.to_s + check('rails.framework_defaults', expected: expected, actual: actual) { actual == expected } + end + + def check_background_job_dependencies + expected_sidekiq = ENV.fetch('EXPECTED_SIDEKIQ_VERSION', '7.3.10') + connection_pool = Gem.loaded_specs.fetch('connection_pool').version + + check('sidekiq.version', expected: expected_sidekiq, actual: Sidekiq::VERSION) { expected_sidekiq == Sidekiq::VERSION } + check('sidekiq.connection_pool', actual: connection_pool.to_s, constraint: '< 3') { connection_pool < Gem::Version.new('3') } + end + + def check_migrations + context = ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths) + check('database.pending_migrations') { !context.needs_migration? } + end + + def check_required_columns + required_columns = { + 'active_storage_blobs' => %w[service_name checksum], + 'active_storage_variant_records' => %w[blob_id variation_digest], + 'users' => %w[otp_secret otp_backup_codes] + } + + missing = required_columns.flat_map do |table, columns| + existing = ActiveRecord::Base.connection.columns(table).map(&:name) + columns.reject { |column| existing.include?(column) }.map { |column| "#{table}.#{column}" } + end + + check('database.required_columns', missing: missing) { missing.empty? } + end + + def check_installation_configs + scanned = 0 + failures = [] + + InstallationConfig.unscoped.find_each do |configuration| + configuration.serialized_value + scanned += 1 + rescue StandardError => e + failures << failure_reference(configuration, e) + end + + check('data.installation_configs', scanned: scanned, failure_samples: failures.first(10)) { failures.empty? } + end + + def check_encrypted_attributes + max_rows = Integer(ENV.fetch('PREFLIGHT_MAX_ENCRYPTED_ROWS', '0'), 10) + checks = encrypted_attribute_checks + check_encryption_keys(checks) + checks.each { |entry| check_encrypted_attribute(entry, max_rows) } + end + + def check_encryption_keys(checks) + encrypted_rows_exist = checks.any? { |entry| entry[:available].positive? } + configured = Chatwoot.encryption_configured? + + check('encryption.keys', configured: configured, encrypted_rows_exist: encrypted_rows_exist) do + !encrypted_rows_exist || configured + end + end + + def check_encrypted_attribute(entry, max_rows) + model = entry.fetch(:model) + attribute = entry.fetch(:attribute) + failures = [] + scanned = scan_encrypted_attribute(model, attribute, max_rows, failures) + + check( + "encryption.#{model.name}.#{attribute}", + available: entry.fetch(:available), + scanned: scanned, + limited: max_rows.positive?, + failure_samples: failures.first(10) + ) { failures.empty? } + end + + def scan_encrypted_attribute(model, attribute, max_rows, failures) + relation = model.unscoped.where.not(attribute => nil) + relation = relation.limit(max_rows) if max_rows.positive? + scanned = 0 + + relation.find_each(batch_size: 500) do |record| + record.public_send(attribute) + scanned += 1 + rescue StandardError => e + failures << failure_reference(record, e) + end + + scanned + end + + def encrypted_attribute_checks + checks = encrypted_models.flat_map do |model| + model.encrypted_attributes.filter_map { |attribute| encrypted_attribute_entry(model, attribute) } + end + checks.sort_by { |entry| [entry.fetch(:model).name, entry.fetch(:attribute).to_s] } + end + + def encrypted_models + ActiveRecord::Base.descendants.select do |model| + attributes = model.encrypted_attributes if model.respond_to?(:encrypted_attributes) + !model.abstract_class? && model.base_class == model && attributes.present? && model.table_exists? + rescue ActiveRecord::StatementInvalid + false + end + end + + def encrypted_attribute_entry(model, attribute) + return unless model.column_names.include?(attribute.to_s) + + { model: model, attribute: attribute, available: model.unscoped.where.not(attribute => nil).count } + end + + def check_active_storage + configurations = Rails.configuration.active_storage.service_configurations.deep_stringify_keys + configured_names = configurations.keys + blob_service_names = ActiveStorage::Blob.unscoped.distinct.pluck(:service_name).compact + unknown_names = blob_service_names - configured_names + expected_azure_service = ENV.fetch('EXPECTED_AZURE_SERVICE', 'AzureBlob') + actual_azure_service = configurations.dig('microsoft', 'service') + + check( + 'active_storage.service_names', + configured: configured_names.sort, + persisted: blob_service_names.sort, + unknown: unknown_names.sort + ) { unknown_names.empty? } + + check( + 'active_storage.azure_adapter', + expected: expected_azure_service, + actual: actual_azure_service + ) { actual_azure_service == expected_azure_service } + end + + def check_selected_blob + blob_id = ENV.fetch('ACTIVE_STORAGE_CHECK_BLOB_ID', nil) + return report('active_storage.selected_blob', true, skipped: true) if blob_id.blank? + + blob = ActiveStorage::Blob.find(blob_id) + check( + 'active_storage.selected_blob', + blob_id: blob.id, + service_name: blob.service_name + ) { blob.service.exist?(blob.key) } + rescue StandardError => e + report('active_storage.selected_blob', false, error_class: e.class.name) + end + + def failure_reference(record, error) + { id: record.id, error_class: error.class.name } + end + + def check(name, details = {}) + report(name, yield, details) + rescue StandardError => e + report(name, false, details.merge(error_class: e.class.name)) + end + + def report(name, passed, details = {}) + @failures += 1 unless passed + puts({ check: name, status: passed ? 'PASS' : 'FAIL' }.merge(details).to_json) + end +end + +RailsUpgrade::Preflight.new.run diff --git a/spec/controllers/api/v1/accounts/macros_controller_spec.rb b/spec/controllers/api/v1/accounts/macros_controller_spec.rb index 1501309a0..10d687d3e 100644 --- a/spec/controllers/api/v1/accounts/macros_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/macros_controller_spec.rb @@ -407,7 +407,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do headers: administrator.create_new_auth_token end - expect(conversation.messages.activity.last.content).to eq("Assigned to #{user_1.name} by #{administrator.name}") + expect(conversation.reload.assignee_id).to eq(user_1.id) end it 'Assign the agent when he is not inbox member' do @@ -421,7 +421,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do headers: administrator.create_new_auth_token end - expect(conversation.messages.activity.last.content).not_to eq("Assigned to #{user_1.name} by #{administrator.name}") + expect(conversation.reload.assignee_id).to be_nil end it 'Assign the labels' do diff --git a/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb b/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb index 96d4dbb0d..0bb18a337 100644 --- a/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb +++ b/spec/mailers/administrator_notifications/shared/smtp_config_shared.rb @@ -1,11 +1,9 @@ # frozen_string_literal: true RSpec.shared_context 'with smtp config' do - before do - # We need to use allow_any_instance_of here because smtp_config_set_or_development? - # is defined in ApplicationMailer and needs to be stubbed for all mailer instances - # rubocop:disable RSpec/AnyInstance - allow_any_instance_of(ApplicationMailer).to receive(:smtp_config_set_or_development?).and_return(true) - # rubocop:enable RSpec/AnyInstance + around do |example| + # Set SMTP_ADDRESS so mailers build a Mail::Message in test without touching real SMTP. + # Scoped to this shared context to avoid affecting other specs. + with_modified_env('SMTP_ADDRESS' => 'smtp.example.com') { example.run } end end diff --git a/spec/models/contact_inbox_spec.rb b/spec/models/contact_inbox_spec.rb index c58615e9e..daaa98305 100644 --- a/spec/models/contact_inbox_spec.rb +++ b/spec/models/contact_inbox_spec.rb @@ -28,8 +28,7 @@ RSpec.describe ContactInbox do obj.reload # ensure the column is nil in database - results = ActiveRecord::Base.connection.execute('Select * from contact_inboxes;') - expect(results.first['pubsub_token']).to be_nil + expect(described_class.where(id: obj.id).pick(:pubsub_token)).to be_nil new_token = obj.pubsub_token obj.update(source_id: '234234323') diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 984cd045f..db1c5807e 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -45,7 +45,7 @@ end RSpec.configure do |config| config.include FactoryBot::Syntax::Methods # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_path = Rails.root.join('spec/fixtures') + config.fixture_paths = [Rails.root.join('spec/fixtures')] # If you're not using ActiveRecord, or you'd prefer not to run each of your # examples within a transaction, remove the following line or assign false diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb index da558e6fc..6262be4ca 100644 --- a/spec/services/telegram/incoming_message_service_spec.rb +++ b/spec/services/telegram/incoming_message_service_spec.rb @@ -42,6 +42,11 @@ describe Telegram::IncomingMessageService do } end + def contact_for(source_id = nil) + source_id ||= message_params.dig('from', 'id') + ContactInbox.find_by!(inbox: telegram_channel.inbox, source_id: source_id).contact + end + describe '#perform' do context 'when valid text message params' do it 'creates appropriate conversations, message and contacts' do @@ -51,7 +56,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.content).to eq('test') end end @@ -64,9 +69,9 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') - expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23) - expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan') + expect(contact_for.name).to eq('Sojan Jose') + expect(contact_for.additional_attributes['social_telegram_user_id']).to eq(23) + expect(contact_for.additional_attributes['social_telegram_user_name']).to eq('sojan') expect(telegram_channel.inbox.messages.first.content).to eq('test') end end @@ -107,7 +112,7 @@ describe Telegram::IncomingMessageService do expect(telegram_channel.inbox.conversations.count).not_to eq(0) expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23, 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' }) - contact = Contact.all.first + contact = contact_for expect(contact.name).to eq('Sojan Jose') expect(contact.additional_attributes['language_code']).to eq('en') message = telegram_channel.inbox.messages.first @@ -131,7 +136,7 @@ describe Telegram::IncomingMessageService do expect(telegram_channel.inbox.conversations.count).not_to eq(0) expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23, 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' }) - contact = Contact.all.first + contact = contact_for expect(contact.name).to eq('Sojan Jose') # TODO: The language code is not present when we send the first message to the client. # Should we update it when the user replies? @@ -161,9 +166,9 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') - expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(23) - expect(Contact.all.first.additional_attributes['social_telegram_user_name']).to eq('sojan') + expect(contact_for.name).to eq('Sojan Jose') + expect(contact_for.additional_attributes['social_telegram_user_id']).to eq(23) + expect(contact_for.additional_attributes['social_telegram_user_name']).to eq('sojan') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio') end end @@ -182,7 +187,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image') end end @@ -207,7 +212,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image') end end @@ -229,7 +234,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video') end end @@ -258,7 +263,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video') end end @@ -277,7 +282,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio') end end @@ -298,7 +303,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') attachment = telegram_channel.inbox.messages.first.attachments.first expect(attachment.file_type).to eq('file') # Retain the original filename from the payload instead of Telegram's internal download name @@ -358,7 +363,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('location') end @@ -377,7 +382,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') attachment = telegram_channel.inbox.messages.first.attachments.first expect(attachment.file_type).to eq('location') @@ -410,8 +415,8 @@ describe Telegram::IncomingMessageService do described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') - expect(Contact.all.first.additional_attributes['social_telegram_user_id']).to eq(5_171_248) + expect(contact_for(5_171_248).name).to eq('Sojan Jose') + expect(contact_for(5_171_248).additional_attributes['social_telegram_user_id']).to eq(5_171_248) expect(telegram_channel.inbox.messages.first.content).to eq('Option 1') end end @@ -428,7 +433,7 @@ describe Telegram::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: telegram_channel.inbox, params: params).perform expect(telegram_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_for.name).to eq('Sojan Jose') expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('contact') end end diff --git a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb index 162f8fc27..bba750806 100644 --- a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb @@ -7,6 +7,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do end let!(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) } + let(:sender_number) { '2423423243' } let(:params) do { phone_number: whatsapp_channel.phone_number, @@ -14,9 +15,9 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do entry: [{ changes: [{ value: { - contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: '2423423243' }], + contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: sender_number }], messages: [{ - from: '2423423243', + from: sender_number, image: { id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683', mime_type: 'image/jpeg', @@ -52,7 +53,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService 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_contact_name expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!') expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be false expect(whatsapp_channel.authorization_error_count).to eq(1) @@ -197,9 +198,9 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do entry: [{ changes: [{ value: { - contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: '2423423243' }], + contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: sender_number }], messages: [{ - from: '2423423243', + from: sender_number, image: { id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683', mime_type: 'image/jpeg', @@ -222,7 +223,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do it 'with attachment errors' do described_class.new(inbox: whatsapp_channel.inbox, params: error_params).perform expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') + expect_contact_name expect(whatsapp_channel.inbox.messages.count).to eq(0) end end @@ -324,7 +325,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do described_class.new(inbox: whatsapp_channel.inbox, params: { phone_number: whatsapp_channel.phone_number, object: 'whatsapp_business_account', entry: {} }).perform expect(whatsapp_channel.inbox.conversations.count).to eq(0) - expect(Contact.all.first).to be_nil + expect(Contact.find_by(phone_number: contact_phone_number)).to be_nil expect(whatsapp_channel.inbox.messages.count).to eq(0) end end @@ -503,7 +504,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do end def expect_contact_name - expect(Contact.all.first.name).to eq('Sojan Jose') + expect(contact_from_number&.name).to eq('Sojan Jose') end def expect_message_content @@ -513,4 +514,12 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do def expect_message_has_attachment expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true end + + def contact_phone_number + "+#{sender_number}" + end + + def contact_from_number + Contact.find_by(phone_number: contact_phone_number) + end end