diff --git a/.bundler-audit.yml b/.bundler-audit.yml index 908d97175..ffbfa18e0 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -2,8 +2,22 @@ ignore: - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) - GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+) - # Chatwoot defaults to Active Storage redirect-style URLs, and its recommended - # storage setup uses local/cloud storage with optional direct uploads to the - # storage provider rather than Rails proxy mode. Revisit if we enable - # rails_storage_proxy or other app-served Active Storage proxy routes. + # Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor. + # Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable. + - GHSA-jp94-3292-c3xv + # Rails 7.1 has no patched release for the Active Storage proxy range + # advisories. Chatwoot limits proxy range requests locally. - CVE-2026-33658 + # Rails 7.1 has no patched release for this Active Storage direct-upload + # advisory. Chatwoot filters internal metadata keys locally. + - CVE-2026-33173 + - CVE-2026-33174 + # Rails 7.1 has no patched release for these Rails advisories. These are not + # reachable through Chatwoot's current usage patterns and should be removed + # once we upgrade to Rails 7.2.3.1+. + - CVE-2026-33168 + - CVE-2026-33169 + - CVE-2026-33170 + - CVE-2026-33176 + - CVE-2026-33195 + - CVE-2026-33202 diff --git a/.circleci/config.yml b/.circleci/config.yml index 59702c139..f764cb611 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,7 +144,7 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 18 + parallelism: 20 steps: - checkout - node/install: diff --git a/Gemfile b/Gemfile index dfcdb3e30..b27b66fde 100644 --- a/Gemfile +++ b/Gemfile @@ -133,9 +133,9 @@ gem 'sentry-ruby', require: false gem 'sentry-sidekiq', '>= 5.19.0', require: false ##-- background job processing --## -gem 'sidekiq', '>= 7.3.1' +gem 'sidekiq', '~> 7.3', '>= 7.3.1' # We want cron jobs -gem 'sidekiq-cron', '>= 1.12.0' +gem 'sidekiq-cron', '>= 2.4.0' # for sidekiq healthcheck gem 'sidekiq_alive' diff --git a/Gemfile.lock b/Gemfile.lock index 15d9b2614..ed1d94172 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -196,6 +196,9 @@ GEM bigdecimal rexml crass (1.0.6) + cronex (0.15.0) + tzinfo + unicode (>= 0.4.4.5) csv (3.3.0) csv-safe (3.3.1) csv (~> 3.0) @@ -212,7 +215,7 @@ GEM logger msgpack datadog-ruby_core_source (3.4.1) - date (3.4.1) + date (3.5.1) debug (1.8.0) irb (>= 1.5.0) reline (>= 0.3.1) @@ -574,7 +577,7 @@ GEM uri (>= 0.11.1) net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.20) + net-imap (0.4.24) date net-protocol net-pop (0.1.2) @@ -590,14 +593,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.3) - nokogiri (1.19.1) + nokogiri (1.19.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.19.1-arm64-darwin) + nokogiri (1.19.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-darwin) + nokogiri (1.19.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-linux-gnu) + nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -902,10 +905,11 @@ GEM logger rack (>= 2.2.4) redis-client (>= 0.22.2) - sidekiq-cron (1.12.0) - fugit (~> 1.8) + sidekiq-cron (2.4.0) + cronex (>= 0.13.0) + fugit (~> 1.8, >= 1.11.1) globalid (>= 1.0.1) - sidekiq (>= 6) + sidekiq (>= 6.5.0) sidekiq_alive (2.5.0) gserver (~> 0.0.1) sidekiq (>= 5, < 9) @@ -961,7 +965,7 @@ GEM time_diff (0.3.0) activesupport i18n - timeout (0.4.3) + timeout (0.6.1) trailblazer-option (0.1.2) twilio-ruby (7.6.0) faraday (>= 0.9, < 3.0) @@ -979,6 +983,7 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.8.2) + unicode (0.4.4.5) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) @@ -1155,8 +1160,8 @@ DEPENDENCIES sentry-sidekiq (>= 5.19.0) shopify_api shoulda-matchers - sidekiq (>= 7.3.1) - sidekiq-cron (>= 1.12.0) + sidekiq (~> 7.3, >= 7.3.1) + sidekiq-cron (>= 2.4.0) sidekiq_alive simplecov (>= 0.21) simplecov_json_formatter diff --git a/VERSION_CW b/VERSION_CW index 813b83b65..c412a4e2e 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.13.0 +4.14.0 diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index be1f98b43..ecd6f06ea 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -28,6 +28,10 @@ class Messages::Messenger::MessageBuilder filename: attachment_file.original_filename, content_type: attachment_file.content_type ) + # The Attachment row is saved before the blob is attached, so the + # after_create_commit broadcast bails on `file.attached?`. Re-fire here + # for audio so the bubble updates without waiting on transcription. + attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio end def attachment_params(attachment) diff --git a/app/builders/notification_builder.rb b/app/builders/notification_builder.rb index d5461eb4e..e09e44d50 100644 --- a/app/builders/notification_builder.rb +++ b/app/builders/notification_builder.rb @@ -27,6 +27,8 @@ class NotificationBuilder return if notification_type == 'conversation_creation' && !user_subscribed_to_notification? # skip notifications for blocked conversations except for user mentions return if primary_actor.contact.blocked? && notification_type != 'conversation_mention' + # respect conversation access (inbox/team membership and custom-role permissions) + return unless user_can_access_conversation? user.notifications.create!( notification_type: notification_type, @@ -36,4 +38,17 @@ class NotificationBuilder secondary_actor: secondary_actor || current_user ) end + + def user_can_access_conversation? + conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation) + return true if conversation.blank? + + account_user = AccountUser.find_by(account_id: account.id, user_id: user.id) + return false if account_user.blank? + + ConversationPolicy.new( + { user: user, account: account, account_user: account_user }, + conversation + ).show? + end end diff --git a/app/builders/v2/reports/bot_metrics_builder.rb b/app/builders/v2/reports/bot_metrics_builder.rb index c46daf978..f151e1a66 100644 --- a/app/builders/v2/reports/bot_metrics_builder.rb +++ b/app/builders/v2/reports/bot_metrics_builder.rb @@ -31,13 +31,24 @@ class V2::Reports::BotMetricsBuilder end def bot_resolutions_count - account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved, - created_at: range).distinct.count + # Exclude conversations that also had a handoff in the same range — handoff wins + account.reporting_events.joins(:conversation).select(:conversation_id) + .where(account_id: account.id, name: :conversation_bot_resolved, created_at: range) + .where.not(conversation_id: bot_handoff_conversation_ids_subquery) + .distinct.count end def bot_handoffs_count - account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff, - created_at: range).distinct.count + account.reporting_events.joins(:conversation).select(:conversation_id) + .where(account_id: account.id, name: :conversation_bot_handoff, created_at: range) + .distinct.count + end + + def bot_handoff_conversation_ids_subquery + account.reporting_events + .where(name: :conversation_bot_handoff, created_at: range) + .where.not(conversation_id: nil) + .select(:conversation_id) end def bot_resolution_rate diff --git a/app/controllers/api/v1/accounts/contacts/attachments_controller.rb b/app/controllers/api/v1/accounts/contacts/attachments_controller.rb new file mode 100644 index 000000000..761f00130 --- /dev/null +++ b/app/controllers/api/v1/accounts/contacts/attachments_controller.rb @@ -0,0 +1,18 @@ +class Api::V1::Accounts::Contacts::AttachmentsController < Api::V1::Accounts::Contacts::BaseController + RESULTS_PER_PAGE = 100 + + def index + conversations = Conversations::PermissionFilterService.new( + Current.account.conversations.where(contact_id: @contact.id), + Current.user, + Current.account + ).perform + + @attachments = Attachment.where(message_id: Message.where(conversation_id: conversations).select(:id)) + .includes({ file_attachment: :blob }, message: [:conversation, :inbox, { sender: { avatar_attachment: :blob } }]) + .order(created_at: :desc) + .page(params[:page]) + .per(RESULTS_PER_PAGE) + @attachments_count = @attachments.total_count + end +end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 6159f804d..6cc77cd54 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def attachments @attachments_count = @conversation.attachments.count @attachments = @conversation.attachments - .includes(:message) + .includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }]) .order(created_at: :desc) .page(attachment_params[:page]) .per(ATTACHMENT_RESULTS_PER_PAGE) diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index c7d3e2737..757af9b62 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -9,7 +9,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController include Api::V1::Accounts::Concerns::WhatsappHealthManagement def index - @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) + @inboxes = policy_scope(Current.account.inboxes) + .includes(:channel, :portal, :working_hours, { avatar_attachment: :blob }) + .order_by_name end def show; end @@ -85,7 +87,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def fetch_agent_bot - @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] + @agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot] end def create_channel diff --git a/app/controllers/api/v1/accounts/notifications_controller.rb b/app/controllers/api/v1/accounts/notifications_controller.rb index 52035ce64..bb5c57ca6 100644 --- a/app/controllers/api/v1/accounts/notifications_controller.rb +++ b/app/controllers/api/v1/accounts/notifications_controller.rb @@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro def destroy_all if params[:type] == 'read' - ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read) + ::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read) else - ::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all) + ::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all) end head :ok end @@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro end def fetch_notification - @notification = current_user.notifications.find(params[:id]) + @notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id]) end def set_current_page diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index ade83d8ec..770018e3c 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -18,7 +18,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController @portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params)) @portal.custom_domain = parsed_custom_domain @portal.save! - process_attached_logo + process_attached_logo if params[:blob_id].present? end def update @@ -78,7 +78,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def portal_params params.require(:portal).permit( :id, :color, :custom_domain, :header_text, :homepage_link, - :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] } + :name, :page_title, :slug, :archived, + { config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] }, + { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] } ) end @@ -87,7 +89,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController return {} unless permitted_params.key?(:inbox_id) return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank? - inbox = Inbox.find(permitted_params[:inbox_id]) + inbox = Current.account.inboxes.find(permitted_params[:inbox_id]) return {} unless inbox.web_widget? { channel_web_widget_id: inbox.channel.id } @@ -98,6 +100,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController end def parsed_custom_domain + return @portal.custom_domain if @portal.custom_domain.blank? + domain = URI.parse(@portal.custom_domain) domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index dddf3dd09..865b387b9 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -111,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController end def custom_attributes_params - params.permit(:industry, :company_size, :timezone, :referral_source, :user_role) + params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website) end def settings_params diff --git a/app/controllers/api/v1/notification_subscriptions_controller.rb b/app/controllers/api/v1/notification_subscriptions_controller.rb index 1a797a74d..bb20c64ae 100644 --- a/app/controllers/api/v1/notification_subscriptions_controller.rb +++ b/app/controllers/api/v1/notification_subscriptions_controller.rb @@ -8,7 +8,8 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController end def destroy - notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first + notification_subscription = current_user.notification_subscriptions + .where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first notification_subscription.destroy! if notification_subscription.present? head :ok end diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index 83b3dc8b1..8b1e8ef48 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -83,6 +83,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController end def set_message - @message = @web_widget.inbox.messages.find(permitted_params[:id]) + # `conversation.messages.find` would be simpler, but `conversation` is `conversations.last`, + # which means a visitor with more than one open thread could not edit a message in any + # but their most recent one. Scoping across all of the visitor's conversations keeps the + # happy path correct for that future multi-conversation widget flow. + @message = Message.where(conversation_id: conversations.select(:id)).find(permitted_params[:id]) end end diff --git a/app/controllers/public/api/v1/inboxes_controller.rb b/app/controllers/public/api/v1/inboxes_controller.rb index 65fad57b1..5679a4a3e 100644 --- a/app/controllers/public/api/v1/inboxes_controller.rb +++ b/app/controllers/public/api/v1/inboxes_controller.rb @@ -24,6 +24,10 @@ class Public::Api::V1::InboxesController < PublicController def set_conversation return if params[:conversation_id].blank? - @conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id]) + @conversation = if @contact_inbox.hmac_verified? + @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id]) + else + @contact_inbox.conversations.find_by!(display_id: params[:conversation_id]) + end end end diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 2bbfafcc7..6a83d08fb 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -1,9 +1,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController - before_action :ensure_custom_domain_request, only: [:show, :index] + before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled before_action :set_category, except: [:index, :show, :tracking_pixel] - before_action :set_article, only: [:show] + before_action :set_article, only: [:show, :show_markdown] layout 'portal' def index @@ -21,6 +23,13 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def show @og_image_url = helpers.set_og_image_url(@portal.name, @article.title) + @parsed_content = render_article_content(@article.content.to_s) + end + + def show_markdown + return head :not_found unless @article&.published? + + render plain: @article.content.to_s, content_type: 'text/markdown; charset=utf-8' end def tracking_pixel @@ -62,7 +71,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def set_article @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) - @parsed_content = render_article_content(@article.content.to_s) end def set_category diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 46158bce9..2991b84d2 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -7,6 +7,8 @@ class Public::Api::V1::Portals::BaseController < PublicController around_action :set_locale after_action :allow_iframe_requests + PORTAL_LAYOUTS = %w[classic documentation].freeze + private def show_plain_layout @@ -17,6 +19,14 @@ class Public::Api::V1::Portals::BaseController < PublicController @theme_from_params = params[:theme] if %w[dark light].include?(params[:theme]) end + def set_portal_layout + @portal_layout = PORTAL_LAYOUTS.include?(@portal&.layout) ? @portal.layout : 'classic' + end + + def set_view_variant + request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled + end + def portal @portal ||= Portal.find_by!(slug: params[:slug], archived: false) end @@ -42,7 +52,7 @@ class Public::Api::V1::Portals::BaseController < PublicController article_locale = if article.category.present? article.category.locale else - article.portal.default_locale + article.locale end @locale = validate_and_get_locale(article_locale) I18n.with_locale(@locale, &) diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index 3fb200269..d1ebe92c6 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -1,12 +1,18 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show, :index] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled before_action :set_category, only: [:show] + before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' } layout 'portal' def index - @categories = @portal.categories.order(position: :asc) + respond_to do |format| + format.html { redirect_to public_portal_locale_path(@portal.slug, params[:locale]), status: :moved_permanently } + format.json { @categories = @portal.categories.order(position: :asc) } + end end def show @@ -21,4 +27,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals: Rails.logger.info "Category: not found for slug: #{params[:category_slug]}" render_404 && return if @category.blank? end + + def load_category_articles + @articles = @category.articles.published.order(:position).includes(:author) + @category_authors = @articles.filter_map(&:author).uniq + end end diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index a187ca8a8..63f44b052 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -2,7 +2,10 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl before_action :ensure_custom_domain_request, only: [:show] before_action :redirect_to_portal_with_locale, only: [:show] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled + before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' } layout 'portal' def show @@ -28,4 +31,28 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl portal redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" end + + def load_home_data + base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) + @visible_categories = @portal.categories + .where(locale: @locale) + .joins(:articles).where(articles: { status: :published }) + .order(position: :asc) + .group('categories.id') + @popular_topics = @visible_categories.first(3) + @featured = base_articles.order_by_views.limit(6) + @category_contributors = build_category_contributors(@visible_categories) + end + + def build_category_contributors(categories) + category_ids = categories.map(&:id) + return {} if category_ids.empty? + + @portal.articles + .published + .where(locale: @locale, category_id: category_ids) + .includes(:author) + .group_by(&:category_id) + .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } + end end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 67d58aef1..86d1b70ef 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController if errors.any? redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ') else - redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully" + redirect_to super_admin_settings_path, flash: success_flash end end @@ -58,6 +58,21 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY] ) end + + def success_notice + message = "#{@config.titleize} settings updated successfully" + return message unless restart_required_config_saved? + + "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." + end + + def success_flash + restart_required_config_saved? ? { success: success_notice } : { notice: success_notice } + end + + def restart_required_config_saved? + params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS) + end end SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController') diff --git a/app/controllers/super_admin/installation_configs_controller.rb b/app/controllers/super_admin/installation_configs_controller.rb index b1f15b518..e888b91e7 100644 --- a/app/controllers/super_admin/installation_configs_controller.rb +++ b/app/controllers/super_admin/installation_configs_controller.rb @@ -25,6 +25,29 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl resource_class.editable end + def create + resource = new_resource(resource_params) + authorize_resource(resource) + + if resource.save + redirect_to after_resource_created_path(resource), flash: success_flash(resource) + else + render :new, locals: { + page: Administrate::Page::Form.new(dashboard, resource) + }, status: :unprocessable_entity + end + end + + def update + if requested_resource.update(resource_params) + redirect_to after_resource_updated_path(requested_resource), flash: success_flash(requested_resource) + else + render :edit, locals: { + page: Administrate::Page::Form.new(dashboard, requested_resource) + }, status: :unprocessable_entity + end + end + # Override `resource_params` if you want to transform the submitted # data before it's persisted. For example, the following would turn all # empty values into nil values. It uses other APIs such as `resource_class` @@ -42,6 +65,20 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl .transform_values { |value| value == '' ? nil : value }.merge(locked: false) end + private + + def success_flash(resource) + message = translate_with_resource('update.success') + message = translate_with_resource('create.success') if action_name == 'create' + return { notice: message } unless restart_required_config?(resource) + + { success: "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." } + end + + def restart_required_config?(resource) + resource.name.in?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS) + end + # See https://administrate-prototype.herokuapp.com/customizing_controller_actions # for more information end diff --git a/app/controllers/swagger_controller.rb b/app/controllers/swagger_controller.rb index af5a4b039..680bf92fc 100644 --- a/app/controllers/swagger_controller.rb +++ b/app/controllers/swagger_controller.rb @@ -1,7 +1,12 @@ class SwaggerController < ApplicationController def respond if Rails.env.development? || Rails.env.test? - render inline: Rails.root.join('swagger', derived_path).read + swagger_root = Rails.root.join('swagger') + file_path = swagger_root.join(derived_path).cleanpath + + return head :not_found unless file_path.to_s.start_with?("#{swagger_root}/") && file_path.file? + + render inline: file_path.read else head :not_found end @@ -11,8 +16,8 @@ class SwaggerController < ApplicationController def derived_path params[:path] ||= 'index.html' - path = Rack::Utils.clean_path_info(params[:path]) - path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s) + path = Rack::Utils.clean_path_info(params[:path]).delete_prefix('/') + path << ".#{Rack::Utils.clean_path_info(params[:format]).delete_prefix('/')}" unless path.ends_with?(params[:format].to_s) path end end diff --git a/app/helpers/file_type_helper.rb b/app/helpers/file_type_helper.rb index 03b807aad..6bae0d3d1 100644 --- a/app/helpers/file_type_helper.rb +++ b/app/helpers/file_type_helper.rb @@ -17,6 +17,7 @@ module FileTypeHelper def image_file?(content_type) [ 'image/jpeg', + 'image/jpg', 'image/png', 'image/gif', 'image/bmp', diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 15de0fbd7..65166145c 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -96,4 +96,15 @@ module PortalHelper colors[username.length % colors.size] end + + def format_authors_label(authors) + return if authors.blank? + + names = authors.map(&:available_name) + return names.to_sentence if names.size <= 3 + + I18n.t('public_portal.sidebar.authors_others', + names: names.first(2).join(', '), + count: authors.size - 2) + end end diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb index 09a84b110..d5b773c87 100644 --- a/app/helpers/report_helper.rb +++ b/app/helpers/report_helper.rb @@ -53,13 +53,12 @@ module ReportHelper end def resolutions - scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, - created_at: range) + scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range) end def bot_resolutions - scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, - created_at: range) + scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range) + .where.not(conversation_id: bot_handoff_conversation_ids_subquery) end def bot_handoffs @@ -67,6 +66,10 @@ module ReportHelper created_at: range).distinct end + def bot_handoff_conversation_ids_subquery + bot_handoffs + end + def avg_first_response_time grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id)) return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours] diff --git a/app/javascript/dashboard/api/captain/document.js b/app/javascript/dashboard/api/captain/document.js index dc22b0c32..e23a8c460 100644 --- a/app/javascript/dashboard/api/captain/document.js +++ b/app/javascript/dashboard/api/captain/document.js @@ -6,15 +6,22 @@ class CaptainDocument extends ApiClient { super('captain/documents', { accountScoped: true }); } - get({ page = 1, searchKey, assistantId } = {}) { + get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) { return axios.get(this.url, { params: { page, - searchKey, + search_key: searchKey, assistant_id: assistantId, + filter, + source, + sort, }, }); } + + sync(id) { + return axios.post(`${this.url}/${id}/sync`); + } } export default new CaptainDocument(); diff --git a/app/javascript/dashboard/api/companies.js b/app/javascript/dashboard/api/companies.js index b98a59983..a45b21d68 100644 --- a/app/javascript/dashboard/api/companies.js +++ b/app/javascript/dashboard/api/companies.js @@ -28,6 +28,14 @@ class CompanyAPI extends ApiClient { return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`); } + listNotes(id) { + return axios.get(`${this.url}/${id}/notes`); + } + + listConversations(id) { + return axios.get(`${this.url}/${id}/conversations`); + } + searchContacts(id, query = '', page = 1) { const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`; return axios.get(requestURL); diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesDetailsLayout.vue b/app/javascript/dashboard/components-next/Companies/CompaniesDetailsLayout.vue index 7736ccffd..14b5e62d9 100644 --- a/app/javascript/dashboard/components-next/Companies/CompaniesDetailsLayout.vue +++ b/app/javascript/dashboard/components-next/Companies/CompaniesDetailsLayout.vue @@ -56,9 +56,14 @@ const closeMobileSidebar = () => {
+ {{ t('COMPANIES.DETAIL.HISTORY.EMPTY') }} +
+ diff --git a/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyNotesSidebar.vue b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyNotesSidebar.vue new file mode 100644 index 000000000..234e0cd12 --- /dev/null +++ b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyNotesSidebar.vue @@ -0,0 +1,114 @@ + + + ++ {{ t('COMPANIES.DETAIL.NOTES.EMPTY') }} +
+ diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue index 773867de1..62bbd8419 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue @@ -32,7 +32,7 @@ const emit = defineEmits([ -<%= render_category_content(article.content) %>
+ diff --git a/app/views/public/api/v1/portals/documentation_layout/_avatar_group.html.erb b/app/views/public/api/v1/portals/documentation_layout/_avatar_group.html.erb new file mode 100644 index 000000000..e3632cb35 --- /dev/null +++ b/app/views/public/api/v1/portals/documentation_layout/_avatar_group.html.erb @@ -0,0 +1,10 @@ +<%# locals: (users:, size: :sm, overlap: '-ml-1.5') %> +<% z_classes = %w[z-30 z-20 z-10] %> + + <% users.first(3).each_with_index do |user, idx| %> + <%= render 'public/api/v1/portals/documentation_layout/user_avatar', + user: user, + size: size, + extra_class: "relative #{z_classes[idx]} #{idx.positive? ? overlap : ''}" %> + <% end %> + diff --git a/app/views/public/api/v1/portals/documentation_layout/_breadcrumb.html.erb b/app/views/public/api/v1/portals/documentation_layout/_breadcrumb.html.erb new file mode 100644 index 000000000..3831e5dfe --- /dev/null +++ b/app/views/public/api/v1/portals/documentation_layout/_breadcrumb.html.erb @@ -0,0 +1,31 @@ +<%# locals: (portal:, locale:, category: nil, article: nil) %> + diff --git a/app/views/public/api/v1/portals/documentation_layout/_category_card.html.erb b/app/views/public/api/v1/portals/documentation_layout/_category_card.html.erb new file mode 100644 index 000000000..fa4ffc024 --- /dev/null +++ b/app/views/public/api/v1/portals/documentation_layout/_category_card.html.erb @@ -0,0 +1,24 @@ +<%# locals: (portal:, category:, contributors: []) %> +<% contributors = Array(contributors) %> + + + <%= category.icon.presence || '📁' %> + +<%= category.description %>
+ <% end %> +<%= local_assigns[:message] || I18n.t('public_portal.common.no_articles') %>
++ <%= I18n.t('public_portal.hero.sub_title') %> +
+ + + + <% if popular_topics.any? %> +<%= subtitle %>
+ <% end %> +<%= article.author.available_name %>
++ <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: last_updated_on) %> +
++ + <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: last_updated_on) %> +
+ <% end %> +<%= category.description %>
+ <% end %> +