diff --git a/.bundler-audit.yml b/.bundler-audit.yml index afe8702ac..7cb453c01 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -1,3 +1,4 @@ --- 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+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 99ac1c29a..59702c139 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -93,8 +93,8 @@ jobs: exit 1 fi mkdir -p ~/tmp - curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar - java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json + curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar + java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json # Bundle audit - run: diff --git a/.gitignore b/.gitignore index dbdd35bcd..017c5c224 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,7 @@ yarn-debug.log* .claude/settings.local.json .cursor .codex/ +.claude/ CLAUDE.local.md # Histoire deployment diff --git a/AGENTS.md b/AGENTS.md index 301633d7f..3db1c34a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,8 +15,7 @@ - **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb` - **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER` - **Run Project**: `overmind start -f Procfile.dev` -- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`) -- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used +- **Ruby Version**: Manage Ruby via `rvm` - Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.) ## Code Style @@ -68,6 +67,15 @@ - Example: `feat(auth): add user authentication` - Don't reference Claude in commit messages +## PR Description Format + +- Start with a short, user-facing paragraph describing the product change. +- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.). +- For feature PRs, add `How to test` from a product/UX standpoint. +- For bugfix PRs, use `How to reproduce` when helpful. +- Optionally add a `What changed` section for implementation highlights. +- Do not add a `How this was tested` section listing specs/commands. + ## Project-Specific - **Translations**: diff --git a/Gemfile b/Gemfile index faec53f56..44db9dc87 100644 --- a/Gemfile +++ b/Gemfile @@ -192,7 +192,7 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents' +gem 'ai-agents', '>= 0.9.1' # TODO: Move this gem as a dependency of ai-agents gem 'ruby_llm', '>= 1.8.2' @@ -271,6 +271,7 @@ group :development, :test do gem 'seed_dump' gem 'shoulda-matchers' gem 'simplecov', '>= 0.21', require: false + gem 'skooma' gem 'spring' gem 'spring-watcher-listen' end diff --git a/Gemfile.lock b/Gemfile.lock index 4f88969f1..c5f2ac99e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,7 +126,7 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.9.0) + ai-agents (0.9.1) ruby_llm (~> 1.9.1) annotaterb (4.20.0) activerecord (>= 6.0.0) @@ -191,7 +191,7 @@ GEM coderay (1.1.3) commonmarker (0.23.10) concurrent-ruby (1.3.5) - connection_pool (2.5.3) + connection_pool (2.5.5) crack (1.0.0) bigdecimal rexml @@ -473,6 +473,12 @@ GEM hana (~> 1.3) regexp_parser (~> 2.0) uri_template (~> 0.7) + json_skooma (0.2.5) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + uri-idna (~> 0.2) + zeitwerk (~> 2.6) judoscale-rails (1.8.2) judoscale-ruby (= 1.8.2) railties @@ -583,14 +589,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.3) - nokogiri (1.18.9) + nokogiri (1.19.1) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.9-arm64-darwin) + nokogiri (1.19.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.9-x86_64-darwin) + nokogiri (1.19.1-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.9-x86_64-linux-gnu) + nokogiri (1.19.1-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -736,7 +742,7 @@ GEM ffi (~> 1.0) redis (5.0.6) redis-client (>= 0.9.0) - redis-client (0.22.2) + redis-client (0.26.4) connection_pool redis-namespace (1.10.0) redis (>= 4) @@ -912,6 +918,9 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + skooma (0.3.7) + json_skooma (~> 0.2.5) + zeitwerk (~> 2.6) slack-ruby-client (2.7.0) faraday (>= 2.0.1) faraday-mashify @@ -974,6 +983,7 @@ GEM unicode-emoji (4.0.4) uniform_notifier (1.17.0) uri (1.1.1) + uri-idna (0.3.1) uri_template (0.7.0) valid_email2 (5.2.6) activemodel (>= 3.2) @@ -1028,7 +1038,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents + ai-agents (>= 0.9.1) annotaterb attr_extras audited (~> 5.4, >= 5.4.1) @@ -1148,6 +1158,7 @@ DEPENDENCIES sidekiq_alive simplecov (>= 0.21) simplecov_json_formatter + skooma slack-ruby-client (~> 2.7.0) spring spring-watcher-listen diff --git a/Makefile b/Makefile index 552ebe659..684adacc6 100644 --- a/Makefile +++ b/Makefile @@ -40,8 +40,12 @@ run: fi force_run: - rm -f ./.overmind.sock - rm -f tmp/pids/*.pid + @echo "Cleaning up Overmind processes..." + @lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true + @lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true + @rm -f ./.overmind.sock + @rm -f tmp/pids/*.pid + @echo "Cleanup complete" overmind start -f Procfile.dev force_run_tunnel: diff --git a/VERSION_CW b/VERSION_CW index a162ea75a..815588ef1 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.11.0 +4.12.0 diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb index d18999bc1..9109f1048 100644 --- a/app/actions/contact_identify_action.rb +++ b/app/actions/contact_identify_action.rb @@ -104,7 +104,7 @@ class ContactIdentifyAction # blank identifier or email will throw unique index error # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded @contact.discard_invalid_attrs if discard_invalid_attrs - @contact.save! + @contact.save! if @contact.changed? enqueue_avatar_job end diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 2c55922f6..1f59deadb 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -105,15 +105,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder end def message_params + content_attributes = { + in_reply_to_external_id: response.in_reply_to_external_id + } + content_attributes[:external_echo] = true if @outgoing_echo + { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: @message_type, + status: @outgoing_echo ? :delivered : :sent, content: response.content, source_id: response.identifier, - content_attributes: { - in_reply_to_external_id: response.in_reply_to_external_id - }, + content_attributes: content_attributes, sender: @outgoing_echo ? nil : @contact_inbox.contact } end diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index 8821da9d5..be1f98b43 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -2,12 +2,17 @@ class Messages::Messenger::MessageBuilder include ::FileTypeHelper def process_attachment(attachment) - # This check handles very rare case if there are multiple files to attach with only one usupported file + # This check handles very rare case if there are multiple files to attach with only one unsupported file return if unsupported_file_type?(attachment['type']) - attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url)) + params = attachment_params(attachment) + attachment_obj = @message.attachments.new(params.except(:remote_file_url)) attachment_obj.save! - attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url] + if facebook_reel?(attachment) + update_facebook_reel_content(attachment) + elsif params[:remote_file_url] + attach_file(attachment_obj, params[:remote_file_url]) + end fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention' fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story' fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post' @@ -26,7 +31,7 @@ class Messages::Messenger::MessageBuilder end def attachment_params(attachment) - file_type = attachment['type'].to_sym + file_type = normalize_file_type(attachment['type']) params = { file_type: file_type, account_id: @message.account_id } if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type @@ -100,6 +105,28 @@ class Messages::Messenger::MessageBuilder private + # Facebook may send attachment types that don't directly match our file_type enum. + # Map known aliases to their canonical enum values. + FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze + + def normalize_file_type(type) + sym = type.to_sym + FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym) + end + + # Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than + # direct video URLs. Downloading these yields HTML, not video content. + def facebook_reel?(attachment) + attachment['type'].to_sym == :reel + end + + def update_facebook_reel_content(attachment) + url = attachment.dig('payload', 'url') + return if url.blank? + + @message.update!(content: url) if @message.content.blank? + end + def unsupported_file_type?(attachment_type) [:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym end diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index 8a6fd61f8..5e1609b64 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController end def reorder - Article.update_positions(params[:positions_hash]) + Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) head :ok end diff --git a/app/controllers/api/v1/accounts/categories_controller.rb b/app/controllers/api/v1/accounts/categories_controller.rb index 834b19ed9..686ffaeec 100644 --- a/app/controllers/api/v1/accounts/categories_controller.rb +++ b/app/controllers/api/v1/accounts/categories_controller.rb @@ -1,7 +1,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController before_action :portal before_action :check_authorization - before_action :fetch_category, except: [:index, :create] + before_action :fetch_category, except: [:index, :create, :reorder] before_action :set_current_page, only: [:index] def index @@ -32,6 +32,11 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle head :ok end + def reorder + Category.update_positions(portal: @portal, positions_hash: params[:positions_hash]) + head :ok + end + private def fetch_category @@ -39,7 +44,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle end def portal - @portal ||= Current.account.portals.find_by(slug: params[:portal_id]) + @portal ||= Current.account.portals.find_by!(slug: params[:portal_id]) end def related_categories_records diff --git a/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb b/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb new file mode 100644 index 000000000..795d7f2a9 --- /dev/null +++ b/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb @@ -0,0 +1,55 @@ +module Api::V1::Accounts::Concerns::WhatsappHealthManagement + extend ActiveSupport::Concern + + included do + skip_before_action :check_authorization, only: [:health, :register_webhook] + before_action :check_admin_authorization?, only: [:register_webhook] + before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook] + end + + def sync_templates + return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel? + + trigger_template_sync + render status: :ok, json: { message: 'Template sync initiated successfully' } + rescue StandardError => e + render status: :internal_server_error, json: { error: e.message } + end + + def health + health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status + render json: health_data + rescue StandardError => e + Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}" + render json: { error: e.message }, status: :unprocessable_entity + end + + def register_webhook + Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback + + render json: { message: 'Webhook registered successfully' }, status: :ok + rescue StandardError => e + Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}" + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def validate_whatsapp_cloud_channel + return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud' + + render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request + end + + def whatsapp_channel? + @inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?) + end + + def trigger_template_sync + if @inbox.whatsapp? + Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) + elsif @inbox.twilio? && @inbox.channel.whatsapp? + Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel) + end + end +end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 2670bc0ba..373df2dba 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -210,7 +210,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController end def fetch_contact - @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) + contact_scope = Current.account.contacts + contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes + @contact = contact_scope.find(params[:id]) end def process_avatar_from_url diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 83873149e..8b23975b2 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def meta - result = conversation_finder.perform + result = conversation_finder.perform_meta_only @conversations_count = result[:count] end @@ -107,7 +107,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def toggle_typing_status - typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params) + typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params) typing_status_manager.toggle_typing_status head :ok end diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb index 0da05e813..051817cf3 100644 --- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb @@ -1,6 +1,7 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController before_action :fetch_inbox before_action :validate_whatsapp_channel + before_action :validate_captain_enabled, only: [:analyze] def show service = CsatTemplateManagementService.new(@inbox) @@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC render json: { error: 'Template parameters are required' }, status: :unprocessable_entity end + def analyze + template_params = extract_template_params + return render_missing_message_error if template_params[:message].blank? + + result = CsatTemplateUtilityAnalysisService.new( + account: Current.account, + inbox: @inbox, + message: template_params[:message], + button_text: template_params[:button_text], + language: template_params[:language] + ).perform + + render json: result + rescue ActionController::ParameterMissing + render json: { error: 'Template parameters are required' }, status: :unprocessable_entity + end + def link link_params = params.require(:template).permit(:name, :language, body_variables: {}) return render json: { error: 'Template name is required' }, status: :unprocessable_entity if link_params[:name].blank? @@ -66,6 +84,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC render json: { error: 'Message is required' }, status: :unprocessable_entity end + def validate_captain_enabled + return if Current.account.feature_enabled?('captain_integration') + + render json: { error: 'Captain is required for template analysis' }, status: :forbidden + end + def render_link_result(result) if result[:success] render json: { diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 265debd2f..ceecce222 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -1,11 +1,14 @@ -class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength +class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController include Api::V1::InboxesHelper before_action :fetch_inbox, except: [:index, :create] before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :validate_limit, only: [:create] # we are already handling the authorization in fetch inbox + # rubocop:disable Rails/LexicallyScopedActionFilter -- health is defined in WhatsappHealthManagement concern before_action :check_authorization, except: [:show, :health, :setup_channel_provider] before_action :validate_whatsapp_cloud_channel, only: [:health] + # rubocop:enable Rails/LexicallyScopedActionFilter + include Api::V1::Accounts::Concerns::WhatsappHealthManagement def index @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) @@ -94,23 +97,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } end - def sync_templates - return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel? - - trigger_template_sync - render status: :ok, json: { message: 'Template sync initiated successfully' } - rescue StandardError => e - render status: :internal_server_error, json: { error: e.message } - end - - def health - health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status - render json: health_data - rescue StandardError => e - Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}" - render json: { error: e.message }, status: :unprocessable_entity - end - def on_whatsapp params.require(:phone_number) phone_number = params[:phone_number] @@ -136,12 +122,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] end - def validate_whatsapp_cloud_channel - return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud' - - render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request - end - def create_channel return unless allowed_channel_types.include?(permitted_params[:channel][:type]) @@ -239,18 +219,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # def get_channel_attributes(channel_type) channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : [] end - - def whatsapp_channel? - @inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?) - end - - def trigger_template_sync - if @inbox.whatsapp? - Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) - elsif @inbox.twilio? && @inbox.channel.whatsapp? - Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel) - end - end end Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index eb6525bb1..9ca0c72fd 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas return unless @hook&.access_token begin - linear_client = Linear.new(@hook.access_token) + linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token')) linear_client.revoke_token rescue StandardError => e Rails.logger.error "Failed to revoke Linear token: #{e.message}" diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 8800d09ff..f7eb8e4ca 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -80,7 +80,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController params.require(:portal).permit( :id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, :custom_head_html, :custom_body_html, - { config: [:default_locale, :show_author, { allowed_locales: [] }] } + { config: [:default_locale, :show_author, { allowed_locales: [] }, { draft_locales: [] }] } ) end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index bcbf80355..3e513a4b2 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -100,7 +100,7 @@ class Api::V1::AccountsController < Api::BaseController end def check_signup_enabled - raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' + raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? end def validate_captcha diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index 32d89f450..955c1eb2c 100644 --- a/app/controllers/api/v1/widget/contacts_controller.rb +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController contact = @contact end - @contact_inbox.update!(hmac_verified: true) if should_verify_hmac? && valid_hmac? + @contact_inbox.update!(hmac_verified: true) if should_verify_hmac? identify_contact(contact) end diff --git a/app/controllers/api/v2/accounts_controller.rb b/app/controllers/api/v2/accounts_controller.rb index bed0a212a..5a19ddeed 100644 --- a/app/controllers/api/v2/accounts_controller.rb +++ b/app/controllers/api/v2/accounts_controller.rb @@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController end def check_signup_enabled - raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' + raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? end def validate_captcha diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index 338b290da..b7fc14e74 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -1,6 +1,6 @@ module AccessTokenAuthHelper BOT_ACCESSIBLE_ENDPOINTS = { - 'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes], + 'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes], 'api/v1/accounts/conversations/messages' => ['create'], 'api/v1/accounts/conversations/assignments' => ['create'] }.freeze @@ -28,7 +28,7 @@ module AccessTokenAuthHelper def validate_bot_access_token! return if Current.user.is_a?(User) - return if agent_bot_accessible? + return if @resource.is_a?(AgentBot) && agent_bot_accessible? render_unauthorized('Access to this endpoint is not authorized for bots') end diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index 900125670..af759af54 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -51,8 +51,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def account_signup_allowed? - # set it to true by default, this is the behaviour across the app - GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false' + GlobalConfigService.account_signup_enabled? end def resource_class(_mapping = nil) diff --git a/app/controllers/linear/callbacks_controller.rb b/app/controllers/linear/callbacks_controller.rb index 2eea49333..cdf6f630e 100644 --- a/app/controllers/linear/callbacks_controller.rb +++ b/app/controllers/linear/callbacks_controller.rb @@ -2,6 +2,8 @@ class Linear::CallbacksController < ApplicationController include Linear::IntegrationHelper def show + return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank? + @response = oauth_client.auth_code.get_token( params[:code], redirect_uri: "#{base_url}/linear/callback" @@ -10,7 +12,7 @@ class Linear::CallbacksController < ApplicationController handle_response rescue StandardError => e Rails.logger.error("Linear callback error: #{e.message}") - redirect_to linear_redirect_uri + redirect_to safe_linear_redirect_uri end private @@ -31,22 +33,19 @@ class Linear::CallbacksController < ApplicationController end def handle_response - hook = account.hooks.new( + raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank? + + hook = account.hooks.find_or_initialize_by(app_id: 'linear') + hook.assign_attributes( access_token: parsed_body['access_token'], status: 'enabled', - app_id: 'linear', - settings: { - token_type: parsed_body['token_type'], - expires_in: parsed_body['expires_in'], - scope: parsed_body['scope'] - } + settings: merged_integration_settings(hook.settings) ) - # You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251 hook.save! redirect_to linear_redirect_uri rescue StandardError => e Rails.logger.error("Linear callback error: #{e.message}") - redirect_to linear_redirect_uri + redirect_to safe_linear_redirect_uri end def account @@ -54,19 +53,47 @@ class Linear::CallbacksController < ApplicationController end def account_id - return unless params[:state] + return @account_id if instance_variable_defined?(:@account_id) - verify_linear_token(params[:state]) + @account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil end def linear_redirect_uri "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear" end + def safe_linear_redirect_uri + return base_url if account_id.blank? + + linear_redirect_uri + rescue StandardError + base_url + end + def parsed_body @parsed_body ||= @response.response.parsed end + def integration_settings + { + token_type: parsed_body['token_type'], + expires_in: parsed_body['expires_in'], + expires_on: expires_on, + scope: parsed_body['scope'], + refresh_token: parsed_body['refresh_token'] + }.compact + end + + def merged_integration_settings(existing_settings) + existing_settings.to_h.with_indifferent_access.merge(integration_settings) + end + + def expires_on + return if parsed_body['expires_in'].blank? + + (Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s + end + def base_url ENV.fetch('FRONTEND_URL', 'http://localhost:3000') end diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index e6cc2aa69..664a1964f 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -6,6 +6,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B layout 'portal' def index + @search_query = list_params[:query] @articles = @portal.articles.published.includes(:category, :author) @articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present? @@ -73,7 +74,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B end def list_params - params.permit(:query, :locale, :sort, :status, :page, :per_page) + @list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted| + permitted[:query] = permitted[:query].to_s.strip.presence + end end def permitted_params diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 7f45ce636..913319303 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -77,13 +77,23 @@ class WidgetsController < ActionController::Base end def allow_iframe_requests - if @web_widget.allowed_domains.blank? + if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin? response.headers.delete('X-Frame-Options') else domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ') response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}" end end + + # Mobile WebViews (iOS/Android) load content from file:// or null origins, + # which cannot match any domain in frame-ancestors. When the per-inbox flag + # is enabled, skip frame-ancestors for these requests. + def embedded_from_non_web_origin? + return false unless @web_widget.allow_mobile_webview? + + origin = request.headers['Origin'] + origin.blank? || origin == 'null' || origin&.start_with?('file://') + end end WidgetsController.prepend_mod_with('WidgetsController') diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb index 753b617ef..b499193cc 100644 --- a/app/dashboards/user_dashboard.rb +++ b/app/dashboards/user_dashboard.rb @@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard current_sign_in_ip: Field::String, last_sign_in_ip: Field::String, confirmation_token: Field::String, - confirmed_at: Field::DateTime, + confirmed_at: ConfirmedAtField, confirmation_sent_at: Field::DateTime, unconfirmed_email: Field::String, name: Field::String.with_options(searchable: true), diff --git a/app/fields/confirmed_at_field.rb b/app/fields/confirmed_at_field.rb new file mode 100644 index 000000000..67e04c4a0 --- /dev/null +++ b/app/fields/confirmed_at_field.rb @@ -0,0 +1,4 @@ +require 'administrate/field/base' + +class ConfirmedAtField < Administrate::Field::DateTime +end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index cd4f91206..a6d897dc0 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -11,6 +11,7 @@ class ConversationFinder 'priority_desc' => %w[sort_on_priority desc], 'waiting_since_asc' => %w[sort_on_waiting_since asc], 'waiting_since_desc' => %w[sort_on_waiting_since desc], + 'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc], # To be removed in v3.5.0 'latest' => %w[sort_on_last_activity_at desc], @@ -55,6 +56,22 @@ class ConversationFinder } end + def perform_meta_only + set_up + + mine_count, unassigned_count, all_count, = set_count_for_all_conversations + assigned_count = all_count - unassigned_count + + { + count: { + mine_count: mine_count, + assigned_count: assigned_count, + unassigned_count: unassigned_count, + all_count: all_count + } + } + end + private def set_up diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index d734a346e..3d6b559c8 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -57,39 +57,35 @@ module Api::V1::InboxesHelper end def check_smtp_connection(channel_data, smtp) + smtp.open_timeout = 10 smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password], channel_data[:smtp_authentication]&.to_sym || :login) smtp.finish + rescue Net::SMTPAuthenticationError + raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error') + rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout + raise StandardError, I18n.t('errors.inboxes.smtp.connection_error') + rescue OpenSSL::SSL::SSLError + raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error') + rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError + raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error') + rescue StandardError => e + raise StandardError, e.message end def set_smtp_encryption(channel_data, smtp) if channel_data[:smtp_enable_ssl_tls] - set_enable_tls(channel_data, smtp) + set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode]) elsif channel_data[:smtp_enable_starttls_auto] - set_enable_starttls_auto(channel_data, smtp) + set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode]) end end - def set_enable_starttls_auto(channel_data, smtp) - return unless smtp.respond_to?(:enable_starttls_auto) + def set_smtp_ssl_method(smtp, method, openssl_verify_mode) + return unless smtp.respond_to?(method) - if channel_data[:smtp_openssl_verify_mode] - context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode]) - smtp.enable_starttls_auto(context) - else - smtp.enable_starttls_auto - end - end - - def set_enable_tls(channel_data, smtp) - return unless smtp.respond_to?(:enable_tls) - - if channel_data[:smtp_openssl_verify_mode] - context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode]) - smtp.enable_tls(context) - else - smtp.enable_tls - end + context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode + context ? smtp.send(method, context) : smtp.send(method) end def enable_openssl_mode(smtp_openssl_verify_mode) diff --git a/app/helpers/filters/filter_helper.rb b/app/helpers/filters/filter_helper.rb index 2bf492915..da74cdd88 100644 --- a/app/helpers/filters/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -47,11 +47,15 @@ module Filters::FilterHelper def handle_additional_attributes(query_hash, filter_operator_value, data_type) if data_type == 'text_case_insensitive' - "LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \ - "#{filter_operator_value} #{query_hash[:query_operator]}" + ActiveRecord::Base.sanitize_sql_array( + ["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}", + query_hash[:attribute_key]] + ) else - "#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \ - "#{filter_operator_value} #{query_hash[:query_operator]} " + ActiveRecord::Base.sanitize_sql_array( + ["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ", + query_hash[:attribute_key]] + ) end end @@ -70,7 +74,7 @@ module Filters::FilterHelper def date_filter(current_filter, query_hash, filter_operator_value) "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \ - "#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}" + "#{filter_operator_value} #{query_hash[:query_operator]}" end def text_case_insensitive_filter(query_hash, filter_operator_value) diff --git a/app/helpers/timezone_helper.rb b/app/helpers/timezone_helper.rb index 7b88ceae6..9e1f99f14 100644 --- a/app/helpers/timezone_helper.rb +++ b/app/helpers/timezone_helper.rb @@ -1,4 +1,10 @@ module TimezoneHelper + def timezone_name_from_params(timezone, offset) + return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present? + + timezone_name_from_offset(offset) + end + # ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset] # would return the timezone without considering day light savings. To get the correct timezone, # this method uses zone.now.utc_offset for comparison as referenced in the issues below diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 61d7631e7..8912c03d1 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -164,10 +164,4 @@ export default { .v-popper--theme-tooltip .v-popper__arrow-container { display: none; } - -.multiselect__input { - margin-bottom: 0px !important; -} - - diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 1e76ac987..bae5623a7 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -57,14 +57,14 @@ class ContactAPI extends ApiClient { return axios.post(`${this.url}/${contactId}/labels`, { labels }); } - search(search = '', page = 1, sortAttr = 'name', label = '') { + search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) { let requestURL = `${this.url}/search?${buildContactParams( page, sortAttr, label, search )}`; - return axios.get(requestURL); + return axios.get(requestURL, { signal: options.signal }); } active(page = 1, sortAttr = 'name') { diff --git a/app/javascript/dashboard/api/helpCenter/categories.js b/app/javascript/dashboard/api/helpCenter/categories.js index 01658497e..eda54aadb 100644 --- a/app/javascript/dashboard/api/helpCenter/categories.js +++ b/app/javascript/dashboard/api/helpCenter/categories.js @@ -25,6 +25,12 @@ class CategoriesAPI extends PortalsAPI { delete({ portalSlug, categoryId }) { return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`); } + + reorder({ portalSlug, reorderedGroup }) { + return axios.post(`${this.url}/${portalSlug}/categories/reorder`, { + positions_hash: reorderedGroup, + }); + } } export default new CategoriesAPI(); diff --git a/app/javascript/dashboard/api/inboxHealth.js b/app/javascript/dashboard/api/inboxHealth.js index 181b041ba..b8f69fcfe 100644 --- a/app/javascript/dashboard/api/inboxHealth.js +++ b/app/javascript/dashboard/api/inboxHealth.js @@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient { getHealthStatus(inboxId) { return axios.get(`${this.url}/${inboxId}/health`); } + + registerWebhook(inboxId) { + return axios.post(`${this.url}/${inboxId}/register_webhook`); + } } export default new InboxHealthAPI(); diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index ef0ec7bf4..aeaea41ab 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -43,6 +43,12 @@ class Inboxes extends CacheEnabledApiClient { return axios.get(`${this.url}/${inboxId}/csat_template`); } + analyzeCSATTemplateUtility(inboxId, template) { + return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, { + template, + }); + } + linkCSATTemplate(inboxId, template) { return axios.post(`${this.url}/${inboxId}/csat_template/link`, { template, diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index 0059518b0..b21aeb102 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -68,7 +68,19 @@ describe('#ContactsAPI', () => { it('#search', () => { contactAPI.search('leads', 1, 'date', 'customer-support'); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support' + '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support', + { signal: undefined } + ); + }); + + it('#search with signal', () => { + const controller = new AbortController(); + contactAPI.search('leads', 1, 'date', 'customer-support', { + signal: controller.signal, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support', + { signal: controller.signal } ); }); diff --git a/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js b/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js index 2c56f4e00..febf6f7a1 100644 --- a/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js +++ b/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js @@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => { expect(categoriesAPI).toHaveProperty('create'); expect(categoriesAPI).toHaveProperty('update'); expect(categoriesAPI).toHaveProperty('delete'); + expect(categoriesAPI).toHaveProperty('reorder'); }); }); diff --git a/app/javascript/dashboard/assets/scss/_base.scss b/app/javascript/dashboard/assets/scss/_base.scss index 14ae2a9d4..84c8a4b0f 100644 --- a/app/javascript/dashboard/assets/scss/_base.scss +++ b/app/javascript/dashboard/assets/scss/_base.scss @@ -66,7 +66,7 @@ textarea { // Field base styles (Input, TextArea, Select) @layer components { .field-base { - @apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6; + @apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6; } .field-disabled { @@ -78,7 +78,7 @@ textarea { } } -$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)"; +$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)"; #{$form-input-selector} { @apply field-base h-10; @@ -92,7 +92,7 @@ $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not } } -input[type='file']:not(.multiselect__input) { +input[type='file'] { @apply leading-[1.15] mb-4 border-0 bg-transparent text-sm; } @@ -126,13 +126,6 @@ label:has(.help-text) { } } -// Error handling -.has-multi-select-error { - div.multiselect { - @apply mb-1; - } -} - // FormKit support .formkit-outer[data-invalid='true'] { #{$form-input-selector}, @@ -150,9 +143,7 @@ label:has(.help-text) { #{$form-input-selector}, input:not([type]), textarea, - select, - .multiselect > .multiselect__tags, - .multiselect:not(.no-margin) { + select { @apply field-error; } diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index 40ca7b436..27764d150 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -13,7 +13,6 @@ @import 'base'; // Plugins -@import 'plugins/multiselect'; @import 'plugins/date-picker'; html, @@ -66,4 +65,84 @@ body { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ } + + /** + * ============================================================================ + * TYPOGRAPHY UTILITIES + * ============================================================================ + * + * | Class | Use Case | + * |--------------------|----------------------------------------------------| + * | .text-body-main |

, , general body text | + * | .text-body-para |

for paragraphs, larger text blocks | + * | .text-heading-1 |

, page titles, panel headers | + * | .text-heading-2 |

, section headings, card titles | + * | .text-heading-3 |

, card headings, breadcrumbs, subsections | + * | .text-label |