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 <scm.mymail@gmail.com> Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
231 lines
8.2 KiB
Ruby
231 lines
8.2 KiB
Ruby
# == Schema Information
|
|
#
|
|
# Table name: users
|
|
#
|
|
# id :integer not null, primary key
|
|
# availability :integer default("online")
|
|
# confirmation_sent_at :datetime
|
|
# confirmation_token :string
|
|
# confirmed_at :datetime
|
|
# consumed_timestep :integer
|
|
# current_sign_in_at :datetime
|
|
# current_sign_in_ip :string
|
|
# custom_attributes :jsonb
|
|
# display_name :string
|
|
# email :string
|
|
# encrypted_password :string default(""), not null
|
|
# last_sign_in_at :datetime
|
|
# last_sign_in_ip :string
|
|
# message_signature :text
|
|
# name :string not null
|
|
# otp_backup_codes :text
|
|
# otp_required_for_login :boolean default(FALSE)
|
|
# otp_secret :string
|
|
# provider :string default("email"), not null
|
|
# pubsub_token :string
|
|
# remember_created_at :datetime
|
|
# reset_password_sent_at :datetime
|
|
# reset_password_token :string
|
|
# sign_in_count :integer default(0), not null
|
|
# tokens :json
|
|
# type :string
|
|
# ui_settings :jsonb
|
|
# uid :string default(""), not null
|
|
# unconfirmed_email :string
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_users_on_email (email)
|
|
# index_users_on_otp_required_for_login (otp_required_for_login)
|
|
# index_users_on_otp_secret (otp_secret) UNIQUE
|
|
# index_users_on_pubsub_token (pubsub_token) UNIQUE
|
|
# index_users_on_reset_password_token (reset_password_token) UNIQUE
|
|
# index_users_on_uid_and_provider (uid,provider) UNIQUE
|
|
#
|
|
|
|
class User < ApplicationRecord
|
|
include AccessTokenable
|
|
include Avatarable
|
|
# Include default devise modules.
|
|
include DeviseTokenAuth::Concerns::User
|
|
include Pubsubable
|
|
include Rails.application.routes.url_helpers
|
|
include Reportable
|
|
include SsoAuthenticatable
|
|
include UserAttributeHelpers
|
|
|
|
devise :database_authenticatable,
|
|
:registerable,
|
|
:recoverable,
|
|
:rememberable,
|
|
:trackable,
|
|
:validatable,
|
|
:confirmable,
|
|
:password_has_required_content,
|
|
:two_factor_authenticatable,
|
|
:omniauthable, omniauth_providers: [:google_oauth2, :saml]
|
|
|
|
# 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 }
|
|
|
|
# The validation below has been commented out as it does not
|
|
# work because :validatable in devise overrides this.
|
|
# validates_uniqueness_of :email, scope: :account_id
|
|
|
|
validates :name, presence: true
|
|
validates :email, presence: true
|
|
|
|
serialize :otp_backup_codes, coder: YAML, type: Array
|
|
|
|
# Encrypt sensitive MFA fields
|
|
encrypts :otp_secret, deterministic: true
|
|
encrypts :otp_backup_codes
|
|
|
|
has_many :account_users, dependent: :destroy_async
|
|
has_many :accounts, through: :account_users
|
|
accepts_nested_attributes_for :account_users
|
|
|
|
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
|
|
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
|
|
has_many :conversation_participants, dependent: :destroy_async
|
|
has_many :participating_conversations, through: :conversation_participants, source: :conversation
|
|
|
|
has_many :inbox_members, dependent: :destroy_async
|
|
has_many :inboxes, through: :inbox_members, source: :inbox
|
|
has_many :messages, as: :sender, dependent: :nullify
|
|
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
|
|
|
|
has_many :user_sessions, dependent: :destroy
|
|
has_many :custom_filters, dependent: :destroy_async
|
|
has_many :dashboard_apps, dependent: :nullify
|
|
has_many :mentions, dependent: :destroy_async
|
|
has_many :notes, dependent: :nullify
|
|
has_many :notification_settings, dependent: :destroy_async
|
|
has_many :notification_subscriptions, dependent: :destroy_async
|
|
has_many :notifications, dependent: :destroy_async
|
|
has_many :team_members, dependent: :destroy_async
|
|
has_many :teams, through: :team_members
|
|
has_many :articles, foreign_key: 'author_id', dependent: :nullify, inverse_of: :author
|
|
# rubocop:disable Rails/HasManyOrHasOneDependent
|
|
# we are handling this in `remove_macros` callback
|
|
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
|
|
# rubocop:enable Rails/HasManyOrHasOneDependent
|
|
|
|
before_validation :set_password_and_uid, on: :create
|
|
after_destroy :remove_macros
|
|
after_save :sync_user_sessions, if: :saved_change_to_tokens?
|
|
|
|
scope :order_by_full_name, -> { order('lower(name) ASC') }
|
|
|
|
before_validation do
|
|
self.email = email.try(:downcase)
|
|
end
|
|
|
|
def send_devise_notification(notification, *)
|
|
devise_mailer.with(account: Current.account).send(notification, self, *).deliver_later
|
|
end
|
|
|
|
def set_password_and_uid
|
|
self.uid = email
|
|
end
|
|
|
|
def assigned_inboxes
|
|
administrator? ? Current.account.inboxes : inboxes.where(account_id: Current.account.id)
|
|
end
|
|
|
|
def serializable_hash(options = nil)
|
|
super(options).merge(confirmed: confirmed?)
|
|
end
|
|
|
|
def push_event_data
|
|
{
|
|
id: id,
|
|
name: name,
|
|
available_name: available_name,
|
|
avatar_url: avatar_url,
|
|
type: 'user',
|
|
availability_status: availability_status,
|
|
thumbnail: avatar_url
|
|
}
|
|
end
|
|
|
|
def webhook_data
|
|
{
|
|
id: id,
|
|
name: name,
|
|
email: email,
|
|
type: 'user'
|
|
}
|
|
end
|
|
|
|
# https://github.com/lynndylanhurley/devise_token_auth/blob/6d7780ee0b9750687e7e2871b9a1c6368f2085a9/app/models/devise_token_auth/concerns/user.rb#L45
|
|
# Since this method is overriden in devise_token_auth it breaks the email reconfirmation flow.
|
|
def will_save_change_to_email?
|
|
mutations_from_database.changed?('email')
|
|
end
|
|
|
|
def self.from_email(email)
|
|
find_by(email: email&.downcase)
|
|
end
|
|
|
|
# 2FA/MFA Methods
|
|
# Delegated to Mfa::ManagementService for better separation of concerns
|
|
def mfa_service
|
|
@mfa_service ||= Mfa::ManagementService.new(user: self)
|
|
end
|
|
|
|
delegate :two_factor_provisioning_uri, to: :mfa_service
|
|
delegate :backup_codes_generated?, to: :mfa_service
|
|
delegate :enable_two_factor!, to: :mfa_service
|
|
delegate :disable_two_factor!, to: :mfa_service
|
|
delegate :generate_backup_codes!, to: :mfa_service
|
|
delegate :validate_backup_code!, to: :mfa_service
|
|
|
|
def mfa_enabled?
|
|
otp_required_for_login?
|
|
end
|
|
|
|
def mfa_feature_available?
|
|
Chatwoot.mfa_enabled?
|
|
end
|
|
|
|
# Workaround for Devise 4.9.x race condition vulnerability (GHSA-57hq-95w6-v4fc).
|
|
#
|
|
# The Confirmable module's reconfirmable flow has a race condition where concurrent
|
|
# email change requests can desynchronize confirmation tokens, allowing an attacker
|
|
# to confirm an email they don't own. Fixed in Devise 5.0.3 by persisting
|
|
# unconfirmed_email before regenerating the confirmation token.
|
|
#
|
|
# We can't upgrade to Devise 5.0.3 because devise-two-factor only added Devise 5
|
|
# support in v6.4.0, which simultaneously raised its Rails minimum to 7.2+.
|
|
# No released version supports both Devise 5 and Rails 7.1.
|
|
#
|
|
# This override applies the same fix locally: force-mark unconfirmed_email
|
|
# as dirty before assignment so ActiveRecord always writes it, keeping it
|
|
# in sync with the regenerated confirmation token. Remove once on Devise 5+.
|
|
def postpone_email_change_until_confirmation_and_regenerate_confirmation_token
|
|
unconfirmed_email_will_change!
|
|
super
|
|
end
|
|
|
|
private
|
|
|
|
def sync_user_sessions
|
|
active_client_ids = (tokens || {}).keys
|
|
user_sessions.where.not(client_id: active_client_ids).destroy_all
|
|
end
|
|
|
|
def remove_macros
|
|
macros.personal.destroy_all
|
|
end
|
|
end
|
|
|
|
User.include_mod_with('Audit::User')
|
|
User.include_mod_with('Concerns::User')
|