From 81fc35e9e6a93f9181c38d560787a8b213c9933b Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 10 Aug 2026 03:19:38 -0700 Subject: [PATCH] chore: upgrade Rails to 7.2.3.1 (#13437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This upgrades Chatwoot to Rails 7.2.3.1 while retaining the current Rails 7.0 framework defaults, so the runtime upgrade can be deployed and observed independently from default-behavior changes. ## What changed - Upgrade Rails and the compatible dependency set to Rails 7.2.3.1. - Keep `config.load_defaults 7.0` for a staged, lower-risk rollout. - Replace the unmaintained Azure Active Storage fork with the maintained `azure-blob` adapter while preserving the `microsoft` service name. - Pin Sidekiq 7.3.10 with `connection_pool` 2.x after validating scheduled-job execution against Redis. - Update Rails 7.2 compatibility surfaces in Active Record, strong parameters, migrations, storage, and tests. - Add read-only production preflight checks, an opt-in Active Storage smoke script, a deployment runbook, and the full Rails 7.2/8.0/8.1 assessment. ## How to test 1. Sign in and verify the dashboard and conversation UI load normally. 2. Open the agent-management modal and confirm agent data is rendered. 3. Create an API inbox and wait for the asynchronous deletion flow to complete. 4. Open Super Admin pages, including instance status and account-user management. 5. Upload and download an attachment using the configured Active Storage service. 6. Confirm recurring Sidekiq Cron jobs register and execute after startup. ## Rollout Follow `docs/rails_upgrades/7_2.md` for pre-deploy checks, deployment order, smoke tests, monitoring, and rollback. Run `bundle exec rails runner script/rails_upgrade/preflight.rb` against a production-equivalent environment before rollout. ## Tracking - [CW-5863 — Upgrade Rails to 8+](https://linear.app/chatwoot/issue/CW-5863/upgrade-rails-to-8) - [Rails 7.2 to 8.1 upgrade and production rollout plan](https://linear.app/chatwoot/document/chatwoot-rails-72-to-81-upgrade-and-production-rollout-plan-44e9f4964cb2) --------- Co-authored-by: Shivam Mishra Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> Co-authored-by: Sony Mathew Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth --- Gemfile | 15 +- Gemfile.lock | 254 +++++---- .../integrations/linear_controller.rb | 2 +- .../instance_statuses_controller.rb | 4 +- app/models/integrations/hook.rb | 6 +- app/models/message.rb | 2 + app/models/user.rb | 6 +- app/services/crm/leadsquared/setup_service.rb | 2 +- config/storage.yml | 3 +- ...0230515051424_update_article_image_keys.rb | 6 +- docs/rails_upgrade_assessment.md | 499 ++++++++++++++++++ docs/rails_upgrades/7_2.md | 97 ++++ lib/seeders/reports/message_creator.rb | 2 +- script/rails_upgrade/active_storage_smoke.rb | 29 + script/rails_upgrade/preflight.rb | 205 +++++++ .../api/v1/accounts/macros_controller_spec.rb | 4 +- .../shared/smtp_config_shared.rb | 10 +- spec/models/contact_inbox_spec.rb | 3 +- spec/rails_helper.rb | 2 +- .../telegram/incoming_message_service_spec.rb | 45 +- ...ing_message_whatsapp_cloud_service_spec.rb | 25 +- 21 files changed, 1032 insertions(+), 189 deletions(-) create mode 100644 docs/rails_upgrade_assessment.md create mode 100644 docs/rails_upgrades/7_2.md create mode 100644 script/rails_upgrade/active_storage_smoke.rb create mode 100644 script/rails_upgrade/preflight.rb 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