diff --git a/.circleci/config.yml b/.circleci/config.yml index 09bd5191d..99ac1c29a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,7 +144,7 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 16 + parallelism: 18 steps: - checkout - node/install: @@ -350,12 +350,12 @@ jobs: destination: coverage build: - <<: *defaults - steps: - - run: - name: Legacy build aggregator - command: | - echo "All main jobs passed; build job kept only for GitHub required check compatibility." + <<: *defaults + steps: + - run: + name: Legacy build aggregator + command: | + echo "All main jobs passed; build job kept only for GitHub required check compatibility." workflows: version: 2 diff --git a/.gitignore b/.gitignore index 8b4fe0971..e7c371733 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,8 @@ yarn-debug.log* .vscode .claude/settings.local.json .cursor +.codex/ +.claude/ CLAUDE.local.md # Histoire deployment @@ -102,3 +104,4 @@ CLAUDE.local.md .pnpm-store/* local/ sw.js +Procfile.worktree diff --git a/AGENTS.md b/AGENTS.md index 474fe6e7f..2ab6373b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,11 @@ - **Setup**: `bundle install && pnpm install` - **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev` +- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification) +- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios) +- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too: + - UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`). + - CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find())"` (or call `Seeders::AccountSeeder.new(account: Account.find()).perform!` directly). - **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix` - **Lint Ruby**: `bundle exec rubocop -a` - **Test JS**: `pnpm test` or `pnpm test:watch` @@ -50,12 +55,28 @@ - Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs - Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors +## Codex Worktree Workflow + +- Use a separate git worktree + branch per task to keep changes isolated. +- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration. +- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions. +- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time. + ## Commit Messages - Prefer Conventional Commits: `type(scope): subject` (scope optional) - 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**: @@ -86,3 +107,7 @@ Practical checklist for any change impacting core logic or public APIs - When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift. - Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable. - When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`. + +## Branding / White-labeling note + +- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy. diff --git a/Gemfile b/Gemfile index 1ae6cf093..0636b7b2b 100644 --- a/Gemfile +++ b/Gemfile @@ -191,12 +191,14 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.7.0' +gem 'ai-agents', '>= 0.9.1' # TODO: Move this gem as a dependency of ai-agents gem 'ruby_llm', '>= 1.8.2' gem 'ruby_llm-schema' +gem 'cld3', '~> 3.7' + # OpenTelemetry for LLM observability gem 'opentelemetry-sdk' gem 'opentelemetry-exporter-otlp' diff --git a/Gemfile.lock b/Gemfile.lock index 1cdfabee0..7a7316e3c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,8 +126,8 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.7.0) - ruby_llm (~> 1.8.2) + ai-agents (0.9.1) + ruby_llm (~> 1.9.1) annotaterb (4.20.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) @@ -186,6 +186,7 @@ GEM byebug (11.1.3) childprocess (5.1.0) logger (~> 1.5) + cld3 (3.7.0) climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) @@ -297,7 +298,7 @@ GEM railties (>= 5.0.0) faker (3.2.0) i18n (>= 1.8.11, < 2) - faraday (2.13.1) + faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json logger @@ -308,12 +309,12 @@ GEM hashie faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (3.4.0) - net-http (>= 0.5.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) faraday-net_http_persistent (2.1.0) faraday (~> 2.5) net-http-persistent (~> 4.0) - faraday-retry (2.2.1) + faraday-retry (2.4.0) faraday (~> 2.0) faraday_middleware-aws-sigv4 (1.0.1) aws-sigv4 (~> 1.0) @@ -464,7 +465,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.13.2) + json (2.18.1) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -539,11 +540,11 @@ GEM net-imap net-pop net-smtp - marcel (1.0.4) + marcel (1.1.0) maxminddb (0.1.22) - meta_request (0.8.3) + meta_request (0.8.5) rack-contrib (>= 1.1, < 3) - railties (>= 3.0.0, < 8) + railties (>= 3.0.0, < 9) method_source (1.1.0) mime-types (3.4.1) mime-types-data (~> 3.2015) @@ -558,12 +559,12 @@ GEM multi_json (1.15.0) multi_xml (0.8.0) bigdecimal (>= 3.1, < 5) - multipart-post (2.3.0) + multipart-post (2.4.1) mutex_m (0.3.0) neighbor (0.2.3) activerecord (>= 5.2) - net-http (0.6.0) - uri + net-http (0.9.1) + uri (>= 0.11.1) net-http-persistent (4.0.2) connection_pool (~> 2.2) net-imap (0.4.20) @@ -582,14 +583,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) @@ -676,7 +677,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.3) + rack (3.2.5) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-contrib (2.5.0) @@ -824,7 +825,7 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_llm (1.8.2) + ruby_llm (1.9.2) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) @@ -968,7 +969,7 @@ GEM unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) uniform_notifier (1.17.0) - uri (1.0.4) + uri (1.1.1) uri_template (0.7.0) valid_email2 (5.2.6) activemodel (>= 3.2) @@ -1003,7 +1004,7 @@ GEM working_hours (1.4.1) activesupport (>= 3.2) tzinfo - zeitwerk (2.6.17) + zeitwerk (2.7.4) PLATFORMS arm64-darwin-20 @@ -1023,7 +1024,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.7.0) + ai-agents (>= 0.9.1) annotaterb attr_extras audited (~> 5.4, >= 5.4.1) @@ -1037,6 +1038,7 @@ DEPENDENCIES bullet bundle-audit byebug + cld3 (~> 3.7) climate_control commonmarker csv-safe 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 ad96464c4..d782fca8f 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.10.1 +4.11.1 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/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb index 818c217ca..8045e84c9 100644 --- a/app/builders/messages/instagram/base_message_builder.rb +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -158,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: message_type, + status: @outgoing_echo ? :delivered : :sent, source_id: message_identifier, content: message_content, sender: @outgoing_echo ? nil : contact, @@ -166,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil } } + params[:content_attributes][:external_echo] = true if @outgoing_echo params[:content_attributes][:is_unsupported] = true if message_is_unsupported? params 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/builders/v2/reports/first_response_time_distribution_builder.rb b/app/builders/v2/reports/first_response_time_distribution_builder.rb new file mode 100644 index 000000000..971542596 --- /dev/null +++ b/app/builders/v2/reports/first_response_time_distribution_builder.rb @@ -0,0 +1,68 @@ +class V2::Reports::FirstResponseTimeDistributionBuilder + include DateRangeHelper + + attr_reader :account, :params + + def initialize(account:, params:) + @account = account + @params = params + end + + def build + build_distribution + end + + private + + def build_distribution + results = fetch_aggregated_counts + map_to_channel_types(results) + end + + def fetch_aggregated_counts + ReportingEvent + .where(account_id: account.id, name: 'first_response') + .where(range_condition) + .group(:inbox_id) + .select( + :inbox_id, + bucket_case_statements + ) + end + + def bucket_case_statements + <<~SQL.squish + COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h, + COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h, + COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h, + COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h, + COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus + SQL + end + + def range_condition + range.present? ? { created_at: range } : {} + end + + def inbox_channel_types + @inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h + end + + def map_to_channel_types(results) + results.each_with_object({}) do |row, hash| + channel_type = inbox_channel_types[row.inbox_id] + next unless channel_type + + hash[channel_type] ||= empty_buckets + hash[channel_type]['0-1h'] += row.bucket_0_1h + hash[channel_type]['1-4h'] += row.bucket_1_4h + hash[channel_type]['4-8h'] += row.bucket_4_8h + hash[channel_type]['8-24h'] += row.bucket_8_24h + hash[channel_type]['24h+'] += row.bucket_24h_plus + end + end + + def empty_buckets + { '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 } + end +end diff --git a/app/builders/v2/reports/outgoing_messages_count_builder.rb b/app/builders/v2/reports/outgoing_messages_count_builder.rb new file mode 100644 index 000000000..ac0de59f2 --- /dev/null +++ b/app/builders/v2/reports/outgoing_messages_count_builder.rb @@ -0,0 +1,79 @@ +class V2::Reports::OutgoingMessagesCountBuilder + include DateRangeHelper + attr_reader :account, :params + + def initialize(account, params) + @account = account + @params = params + end + + def build + send("build_by_#{params[:group_by]}") + end + + private + + def base_messages + account.messages.outgoing.unscope(:order).where(created_at: range) + end + + def build_by_agent + counts = base_messages + .where(sender_type: 'User') + .where.not(sender_id: nil) + .group(:sender_id) + .count + + user_names = account.users.where(id: counts.keys).index_by(&:id) + + counts.map do |user_id, count| + user = user_names[user_id] + { id: user_id, name: user&.name, outgoing_messages_count: count } + end + end + + def build_by_team + counts = base_messages + .joins('INNER JOIN conversations ON messages.conversation_id = conversations.id') + .where.not(conversations: { team_id: nil }) + .group('conversations.team_id') + .count + + team_names = account.teams.where(id: counts.keys).index_by(&:id) + + counts.map do |team_id, count| + team = team_names[team_id] + { id: team_id, name: team&.name, outgoing_messages_count: count } + end + end + + def build_by_inbox + counts = base_messages + .group(:inbox_id) + .count + + inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id) + + counts.map do |inbox_id, count| + inbox = inbox_names[inbox_id] + { id: inbox_id, name: inbox&.name, outgoing_messages_count: count } + end + end + + def build_by_label + counts = base_messages + .joins('INNER JOIN conversations ON messages.conversation_id = conversations.id') + .joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id + AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'") + .joins('INNER JOIN tags ON tags.id = taggings.tag_id') + .group('tags.name') + .count + + label_ids = account.labels.where(title: counts.keys).index_by(&:title) + + counts.map do |label_name, count| + label = label_ids[label_name] + { id: label&.id, name: label_name, outgoing_messages_count: count } + end + end +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/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index e2b930ac9..52d829441 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 @@ -70,8 +70,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def transcript render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank? + return render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled? + return head :too_many_requests unless @conversation.account.within_email_rate_limit? ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later + @conversation.account.increment_email_sent_count head :ok end @@ -104,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 bb5dab680..d7244616d 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 + private def fetch_inbox @@ -46,6 +64,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_template_creation_result(result) if result[:success] render_successful_template_creation(result) diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index fe5facc1a..00e718614 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -35,12 +35,11 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def transcript - if conversation.present? && conversation.contact.present? && conversation.contact.email.present? - ConversationReplyMailer.with(account: conversation.account).conversation_transcript( - conversation, - conversation.contact.email - )&.deliver_later - end + return head :too_many_requests if conversation.blank? + return head :payment_required unless conversation.account.email_transcript_enabled? + return head :too_many_requests unless conversation.account.within_email_rate_limit? + + send_transcript_email head :ok end @@ -77,6 +76,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController private + def send_transcript_email + return if conversation.contact&.email.blank? + + ConversationReplyMailer.with(account: conversation.account).conversation_transcript( + conversation, + conversation.contact.email + )&.deliver_later + conversation.account.increment_email_sent_count + end + def trigger_typing_event(event) Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact) end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 82576cf90..192b3619c 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -70,6 +70,23 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render json: builder.build end + def first_response_time_distribution + builder = V2::Reports::FirstResponseTimeDistributionBuilder.new( + account: Current.account, + params: first_response_time_distribution_params + ) + render json: builder.build + end + + OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze + + def outgoing_messages_count + return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by]) + + builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params) + render json: builder.build + end + private def generate_csv(filename, template) @@ -156,4 +173,19 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController label_ids: params[:label_ids] } end + + def first_response_time_distribution_params + { + since: params[:since], + until: params[:until] + } + end + + def outgoing_messages_count_params + { + group_by: params[:group_by], + since: params[:since], + until: params[:until] + } + end end 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/passwords_controller.rb b/app/controllers/devise_overrides/passwords_controller.rb index 00976c3cd..c69541f6f 100644 --- a/app/controllers/devise_overrides/passwords_controller.rb +++ b/app/controllers/devise_overrides/passwords_controller.rb @@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController def create @user = User.from_email(params[:email]) - if @user - @user.send_reset_password_instructions - build_response(I18n.t('messages.reset_password_success'), 200) - else - build_response(I18n.t('messages.reset_password_failure'), 404) - end + @user&.send_reset_password_instructions + build_response(I18n.t('messages.reset_password'), 200) end def update diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index b910a9c9a..67d58aef1 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -42,7 +42,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT], 'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET], 'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET], - 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], + 'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], diff --git a/app/controllers/webhooks/shopify_controller.rb b/app/controllers/webhooks/shopify_controller.rb new file mode 100644 index 000000000..efc6a5122 --- /dev/null +++ b/app/controllers/webhooks/shopify_controller.rb @@ -0,0 +1,35 @@ +class Webhooks::ShopifyController < ActionController::API + before_action :verify_hmac! + + def events + case request.headers['X-Shopify-Topic'] + when 'shop/redact' + handle_shop_redact + end + + head :ok + end + + private + + def verify_hmac! + secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil) + return head :unauthorized if secret.blank? + + data = request.body.read + request.body.rewind + + hmac_header = request.headers['X-Shopify-Hmac-SHA256'] + return head :unauthorized if hmac_header.blank? + + computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data)) + return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header) + end + + def handle_shop_redact + shop_domain = params[:shop_domain] + return if shop_domain.blank? + + Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all + end +end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index d43ed31e7..fa437327d 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/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/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 83ba3e9ba..079f21815 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -42,6 +42,12 @@ class Inboxes extends CacheEnabledApiClient { getCSATTemplateStatus(inboxId) { return axios.get(`${this.url}/${inboxId}/csat_template`); } + + analyzeCSATTemplateUtility(inboxId, template) { + return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, { + template, + }); + } } export default new Inboxes(); 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 |