diff --git a/.annotaterb.yml b/.annotaterb.yml new file mode 100644 index 000000000..07162a22d --- /dev/null +++ b/.annotaterb.yml @@ -0,0 +1,65 @@ +--- +:position: before +:position_in_additional_file_patterns: before +:position_in_class: before +:position_in_factory: before +:position_in_fixture: before +:position_in_routes: before +:position_in_serializer: before +:position_in_test: before +:classified_sort: true +:exclude_controllers: true +:exclude_factories: true +:exclude_fixtures: true +:exclude_helpers: true +:exclude_scaffolds: true +:exclude_serializers: true +:exclude_sti_subclasses: false +:exclude_tests: true +:force: false +:format_markdown: false +:format_rdoc: false +:format_yard: false +:frozen: false +:grouped_polymorphic: false +:ignore_model_sub_dir: false +:ignore_unknown_models: false +:include_version: false +:show_check_constraints: false +:show_complete_foreign_keys: false +:show_foreign_keys: true +:show_indexes: true +:show_indexes_include: false +:simple_indexes: false +:sort: false +:timestamp: false +:trace: false +:with_comment: true +:with_column_comments: true +:with_table_comments: true +:position_of_column_comment: :with_name +:active_admin: false +:command: +:debug: false +:hide_default_column_types: json,jsonb,hstore +:hide_limit_column_types: integer,bigint,boolean +:timestamp_columns: +- created_at +- updated_at +:ignore_columns: +:ignore_routes: +:models: true +:routes: false +:skip_on_db_migrate: false +:target_action: :do_annotations +:wrapper: +:wrapper_close: +:wrapper_open: +:classes_default_to_s: [] +:additional_file_patterns: [] +:model_dir: +- app/models +- enterprise/app/models +:require: [] +:root_dir: +- '' diff --git a/.bundler-audit.yml b/.bundler-audit.yml index afe8702ac..908d97175 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -1,3 +1,9 @@ --- 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. + - CVE-2026-33658 diff --git a/.circleci/config.yml b/.circleci/config.yml index 09bd5191d..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: @@ -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/.env.example b/.env.example index 55750b2f2..bc7380a29 100644 --- a/.env.example +++ b/.env.example @@ -276,3 +276,4 @@ AZURE_APP_SECRET= # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false # REDIS_ALFRED_SIZE=10 +# REDIS_VELMA_SIZE=10 diff --git a/.gitignore b/.gitignore index bcc83c1ef..017c5c224 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 @@ -101,3 +103,4 @@ CLAUDE.local.md .histoire .pnpm-store/* local/ +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..a5068e765 100644 --- a/Gemfile +++ b/Gemfile @@ -40,6 +40,8 @@ gem 'json_refs' gem 'rack-attack', '>= 6.7.0' # a utility tool for streaming, flexible and safe downloading of remote files gem 'down' +# SSRF-safe URL fetching +gem 'ssrf_filter', '~> 1.5' # authentication type to fetch and send mail over oauth2.0 gem 'gmail_xoauth' # Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2 @@ -191,12 +193,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' @@ -266,6 +270,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 1cdfabee0..b77e5880f 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) @@ -166,7 +166,7 @@ GEM multi_json (~> 1) statsd-ruby (~> 1.1) base64 (0.3.0) - bcrypt (3.1.20) + bcrypt (3.1.22) benchmark (0.4.1) bigdecimal (3.2.2) bindex (0.8.1) @@ -186,11 +186,12 @@ 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) concurrent-ruby (1.3.5) - connection_pool (2.5.3) + connection_pool (2.5.5) crack (1.0.0) bigdecimal rexml @@ -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.19.2) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -472,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 @@ -539,11 +546,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 +565,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 +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) @@ -676,7 +683,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) @@ -735,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) @@ -824,7 +831,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) @@ -909,6 +916,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 @@ -932,6 +942,7 @@ GEM activesupport (>= 5.2) sprockets (>= 3.0.0) squasher (0.7.2) + ssrf_filter (1.5.0) stackprof (0.2.25) statsd-ruby (1.5.0) stripe (18.0.1) @@ -968,7 +979,8 @@ 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-idna (0.3.1) uri_template (0.7.0) valid_email2 (5.2.6) activemodel (>= 3.2) @@ -1003,7 +1015,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 +1035,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 +1049,7 @@ DEPENDENCIES bullet bundle-audit byebug + cld3 (~> 3.7) climate_control commonmarker csv-safe @@ -1141,10 +1154,12 @@ DEPENDENCIES sidekiq_alive simplecov (>= 0.21) simplecov_json_formatter + skooma slack-ruby-client (~> 2.7.0) spring spring-watcher-listen squasher + ssrf_filter (~> 1.5) stackprof stripe (~> 18.0) telephone_number 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..813b83b65 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.10.1 +4.13.0 diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb index bcf5a93c3..9afa5d019 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/email/base_builder.rb b/app/builders/email/base_builder.rb index 731b1b0f5..a6da58792 100644 --- a/app/builders/email/base_builder.rb +++ b/app/builders/email/base_builder.rb @@ -1,4 +1,6 @@ class Email::BaseBuilder + include EmailAddressParseable + pattr_initialize [:inbox!] private @@ -39,7 +41,7 @@ class Email::BaseBuilder end def business_name - inbox.business_name || inbox.sanitized_name + inbox.sanitized_business_name end def account_support_email @@ -47,8 +49,4 @@ class Email::BaseBuilder # can save it in the format "Name " parse_email(account.support_email) end - - def parse_email(email_string) - Mail::Address.new(email_string).address - end 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/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb index 8b40ba3c9..8045e84c9 100644 --- a/app/builders/messages/instagram/base_message_builder.rb +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -112,6 +112,25 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil return if story_reply_attributes.blank? @message.save_story_info(story_reply_attributes) + create_story_reply_attachment(story_reply_attributes['url']) + end + + def create_story_reply_attachment(story_url) + return if story_url.blank? + + attachment = @message.attachments.new( + file_type: :ig_story, + account_id: @message.account_id, + external_url: story_url + ) + attachment.save! + begin + attach_file(attachment, story_url) + rescue Down::Error, StandardError => e + Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}" + end + @message.content_attributes[:image_type] = 'ig_story_reply' + @message.save! end def build_conversation @@ -139,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, @@ -147,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/report_builder.rb b/app/builders/v2/report_builder.rb index fb986d335..b935bd520 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -1,6 +1,7 @@ class V2::ReportBuilder include DateRangeHelper include ReportHelper + attr_reader :account, :params DEFAULT_GROUP_BY = 'day'.freeze diff --git a/app/builders/v2/reports/agent_summary_builder.rb b/app/builders/v2/reports/agent_summary_builder.rb index 9b4541aaa..58689aaad 100644 --- a/app/builders/v2/reports/agent_summary_builder.rb +++ b/app/builders/v2/reports/agent_summary_builder.rb @@ -11,10 +11,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder attr_reader :conversations_count, :resolved_count, :avg_resolution_time, :avg_first_response_time, :avg_reply_time - def fetch_conversations_count - account.conversations.where(created_at: range).group('assignee_id').count - end - def prepare_report account.account_users.map do |account_user| build_agent_stats(account_user) diff --git a/app/builders/v2/reports/base_summary_builder.rb b/app/builders/v2/reports/base_summary_builder.rb index d4a9e7c0b..bd8ed5f56 100644 --- a/app/builders/v2/reports/base_summary_builder.rb +++ b/app/builders/v2/reports/base_summary_builder.rb @@ -9,37 +9,13 @@ class V2::Reports::BaseSummaryBuilder private def load_data - @conversations_count = fetch_conversations_count - load_reporting_events_data - end + results = data_source.summary - def load_reporting_events_data - # Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id') - index_key = group_by_key.to_s.split('.').last - - results = reporting_events - .select( - "#{group_by_key} as #{index_key}", - "COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count", - "AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time", - "AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time", - "AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time" - ) - .group(group_by_key) - .index_by { |record| record.public_send(index_key) } - - @resolved_count = results.transform_values(&:resolved_count) - @avg_resolution_time = results.transform_values(&:avg_resolution_time) - @avg_first_response_time = results.transform_values(&:avg_first_response_time) - @avg_reply_time = results.transform_values(&:avg_reply_time) - end - - def reporting_events - @reporting_events ||= account.reporting_events.where(created_at: range) - end - - def fetch_conversations_count - # Override this method + @conversations_count = results.transform_values { |data| data[:conversations_count] } + @resolved_count = results.transform_values { |data| data[:resolved_conversations_count] } + @avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] } + @avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] } + @avg_reply_time = results.transform_values { |data| data[:avg_reply_time] } end def group_by_key @@ -50,7 +26,26 @@ class V2::Reports::BaseSummaryBuilder # Override this method end - def average_value_key - ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value + def data_source + @data_source ||= Reports::DataSource.for( + account: account, + metric: nil, + dimension_type: summary_dimension_type, + dimension_id: nil, + scope: nil, + range: range, + group_by: 'day', + timezone_offset: params[:timezone_offset], + business_hours: params[:business_hours] + ) + end + + def summary_dimension_type + { + 'account_id' => 'account', + 'user_id' => 'agent', + 'inbox_id' => 'inbox', + 'conversations.team_id' => 'team' + }.fetch(group_by_key.to_s) end end diff --git a/app/builders/v2/reports/conversations/base_report_builder.rb b/app/builders/v2/reports/conversations/base_report_builder.rb index a7961b0d6..ee0155150 100644 --- a/app/builders/v2/reports/conversations/base_report_builder.rb +++ b/app/builders/v2/reports/conversations/base_report_builder.rb @@ -3,23 +3,10 @@ class V2::Reports::Conversations::BaseReportBuilder private - AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze - COUNT_METRICS = %w[ - conversations_count - incoming_messages_count - outgoing_messages_count - resolutions_count - bot_resolutions_count - bot_handoffs_count - ].freeze - def builder_class(metric) - case metric - when *AVG_METRICS - V2::Reports::Timeseries::AverageReportBuilder - when *COUNT_METRICS - V2::Reports::Timeseries::CountReportBuilder - end + return unless Reports::ReportMetricRegistry.supported?(metric) + + V2::Reports::Timeseries::ReportBuilder end def log_invalid_metric 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/inbox_label_matrix_builder.rb b/app/builders/v2/reports/inbox_label_matrix_builder.rb new file mode 100644 index 000000000..c3715019d --- /dev/null +++ b/app/builders/v2/reports/inbox_label_matrix_builder.rb @@ -0,0 +1,65 @@ +class V2::Reports::InboxLabelMatrixBuilder + include DateRangeHelper + + attr_reader :account, :params + + def initialize(account:, params:) + @account = account + @params = params + end + + def build + { + inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } }, + labels: filtered_labels.map { |label| { id: label.id, title: label.title } }, + matrix: build_matrix + } + end + + private + + def filtered_inboxes + @filtered_inboxes ||= begin + inboxes = account.inboxes + inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present? + inboxes.order(:name).to_a + end + end + + def filtered_labels + @filtered_labels ||= begin + labels = account.labels + labels = labels.where(id: params[:label_ids]) if params[:label_ids].present? + labels.order(:title).to_a + end + end + + def conversation_filter + filter = { account_id: account.id } + filter[:created_at] = range if range.present? + filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present? + filter + end + + def fetch_grouped_counts + label_names = filtered_labels.map(&:title) + return {} if label_names.empty? + + ActsAsTaggableOn::Tagging + .joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter) + .where(tags: { name: label_names }) + .group('conversations.inbox_id', 'tags.name') + .count + end + + def build_matrix + counts = fetch_grouped_counts + filtered_inboxes.map do |inbox| + filtered_labels.map do |label| + counts[[inbox.id, label.title]] || 0 + end + end + end +end diff --git a/app/builders/v2/reports/inbox_summary_builder.rb b/app/builders/v2/reports/inbox_summary_builder.rb index 935afeb82..fcfabc599 100644 --- a/app/builders/v2/reports/inbox_summary_builder.rb +++ b/app/builders/v2/reports/inbox_summary_builder.rb @@ -11,15 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder attr_reader :conversations_count, :resolved_count, :avg_resolution_time, :avg_first_response_time, :avg_reply_time - def load_data - @conversations_count = fetch_conversations_count - load_reporting_events_data - end - - def fetch_conversations_count - account.conversations.where(created_at: range).group(group_by_key).count - end - def prepare_report account.inboxes.map do |inbox| build_inbox_stats(inbox) @@ -40,8 +31,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder def group_by_key :inbox_id end - - def average_value_key - ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value - 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/builders/v2/reports/team_summary_builder.rb b/app/builders/v2/reports/team_summary_builder.rb index b98151bc6..3bcf51816 100644 --- a/app/builders/v2/reports/team_summary_builder.rb +++ b/app/builders/v2/reports/team_summary_builder.rb @@ -6,14 +6,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder attr_reader :conversations_count, :resolved_count, :avg_resolution_time, :avg_first_response_time, :avg_reply_time - def fetch_conversations_count - account.conversations.where(created_at: range).group(:team_id).count - end - - def reporting_events - @reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation) - end - def prepare_report account.teams.map do |team| build_team_stats(team) diff --git a/app/builders/v2/reports/timeseries/average_report_builder.rb b/app/builders/v2/reports/timeseries/average_report_builder.rb deleted file mode 100644 index 5df718b6a..000000000 --- a/app/builders/v2/reports/timeseries/average_report_builder.rb +++ /dev/null @@ -1,48 +0,0 @@ -class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder - def timeseries - grouped_average_time = reporting_events.average(average_value_key) - grouped_event_count = reporting_events.count - grouped_average_time.each_with_object([]) do |element, arr| - event_date, average_time = element - arr << { - value: average_time, - timestamp: event_date.in_time_zone(timezone).to_i, - count: grouped_event_count[event_date] - } - end - end - - def aggregate_value - object_scope.average(average_value_key) - end - - private - - def event_name - metric_to_event_name = { - avg_first_response_time: :first_response, - avg_resolution_time: :conversation_resolved, - reply_time: :reply_time - } - metric_to_event_name[params[:metric].to_sym] - end - - def object_scope - scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id) - end - - def reporting_events - @grouped_values = object_scope.group_by_period( - group_by, - :created_at, - default_value: 0, - range: range, - permit: %w[day week month year hour], - time_zone: timezone - ) - end - - def average_value_key - @average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value - end -end diff --git a/app/builders/v2/reports/timeseries/base_timeseries_builder.rb b/app/builders/v2/reports/timeseries/base_timeseries_builder.rb index 50699417d..86fddba07 100644 --- a/app/builders/v2/reports/timeseries/base_timeseries_builder.rb +++ b/app/builders/v2/reports/timeseries/base_timeseries_builder.rb @@ -1,12 +1,13 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder include TimezoneHelper include DateRangeHelper + DEFAULT_GROUP_BY = 'day'.freeze pattr_initialize :account, :params def scope - case params[:type].to_sym + case dimension_type.to_sym when :account account when :inbox @@ -20,6 +21,20 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder end end + def data_source + @data_source ||= Reports::DataSource.for( + account: account, + metric: params[:metric], + dimension_type: dimension_type, + dimension_id: params[:id], + scope: scope, + range: range, + group_by: group_by, + timezone_offset: params[:timezone_offset], + business_hours: params[:business_hours] + ) + end + def inbox @inbox ||= account.inboxes.find(params[:id]) end @@ -43,4 +58,10 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder def timezone @timezone ||= timezone_name_from_offset(params[:timezone_offset]) end + + private + + def dimension_type + (params[:type].presence || 'account').to_s + end end diff --git a/app/builders/v2/reports/timeseries/count_report_builder.rb b/app/builders/v2/reports/timeseries/count_report_builder.rb deleted file mode 100644 index bb3b1250c..000000000 --- a/app/builders/v2/reports/timeseries/count_report_builder.rb +++ /dev/null @@ -1,78 +0,0 @@ -class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder - def timeseries - grouped_count.each_with_object([]) do |element, arr| - event_date, event_count = element - - # The `event_date` is in Date format (without time), such as "Wed, 15 May 2024". - # We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i` - # because it converts the date to 12:00 AM server timezone. - # The desired output should be 12:00 AM in the specified timezone. - arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i } - end - end - - def aggregate_value - object_scope.count - end - - private - - def metric - @metric ||= params[:metric] - end - - def object_scope - send("scope_for_#{metric}") - end - - def scope_for_conversations_count - scope.conversations.where(account_id: account.id, created_at: range) - end - - def scope_for_incoming_messages_count - scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order) - end - - def scope_for_outgoing_messages_count - scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order) - end - - def scope_for_resolutions_count - scope.reporting_events.where( - name: :conversation_resolved, - account_id: account.id, - created_at: range - ) - end - - def scope_for_bot_resolutions_count - scope.reporting_events.where( - name: :conversation_bot_resolved, - account_id: account.id, - created_at: range - ) - end - - def scope_for_bot_handoffs_count - scope.reporting_events.joins(:conversation).select(:conversation_id).where( - name: :conversation_bot_handoff, - account_id: account.id, - created_at: range - ).distinct - end - - def grouped_count - # IMPORTANT: time_zone parameter affects both data grouping AND output timestamps - # It converts timestamps to the target timezone before grouping, which means - # the same event can fall into different day buckets depending on timezone - # Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day) - @grouped_values = object_scope.group_by_period( - group_by, - :created_at, - default_value: 0, - range: range, - permit: %w[day week month year hour], - time_zone: timezone - ).count - end -end diff --git a/app/builders/v2/reports/timeseries/report_builder.rb b/app/builders/v2/reports/timeseries/report_builder.rb new file mode 100644 index 000000000..a3e83c78a --- /dev/null +++ b/app/builders/v2/reports/timeseries/report_builder.rb @@ -0,0 +1,9 @@ +class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder + def timeseries + data_source.timeseries + end + + def aggregate_value + data_source.aggregate + end +end diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index c2f919659..de3d10081 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -34,6 +34,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController @agent_bot.reload end + def reset_secret + @agent_bot.reset_secret! + end + private def agent_bot 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/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb b/app/controllers/api/v1/accounts/captain/tasks_controller.rb similarity index 93% rename from enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb rename to app/controllers/api/v1/accounts/captain/tasks_controller.rb index d7208d678..9ba197a3c 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb +++ b/app/controllers/api/v1/accounts/captain/tasks_controller.rb @@ -57,7 +57,7 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr if result.nil? render json: { message: nil } elsif result[:error] - render json: { error: result[:error] }, status: :unprocessable_entity + render json: { error: result[:error] }, status: :unprocessable_content else response_data = { message: result[:message] } response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context] @@ -69,3 +69,5 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr authorize(:'captain/tasks') end end + +Api::V1::Accounts::Captain::TasksController.prepend_mod_with('Api::V1::Accounts::Captain::TasksController') 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 e6270c807..dd5346bd6 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -24,13 +24,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def search render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return - contacts = resolved_contacts.where( - 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search - OR contacts.additional_attributes->>\'company_name\' ILIKE :search', + contacts = Current.account.contacts.where( + 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search', search: "%#{params[:q].strip}%" ) - @contacts = fetch_contacts(contacts) - @contacts_count = @contacts.total_count + @contacts = fetch_contacts_with_has_more(contacts) end def import @@ -143,6 +141,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController .per(RESULTS_PER_PAGE) end + def fetch_contacts_with_has_more(contacts) + includes_hash = { avatar_attachment: [:blob] } + includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes + + # Calculate offset manually to fetch one extra record for has_more check + offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE + results = filtrate(contacts) + .includes(includes_hash) + .offset(offset) + .limit(RESULTS_PER_PAGE + 1) + .to_a + + @has_more = results.size > RESULTS_PER_PAGE + results = results.first(RESULTS_PER_PAGE) if @has_more + @contacts_count = results.size + results + end + def build_contact_inbox return if params[:inbox_id].blank? @@ -185,7 +201,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 4301eaa4a..6159f804d 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,12 +107,23 @@ 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 def update_last_seen + # High-traffic accounts generate excessive DB writes when agents frequently switch between conversations. + # Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load. + # Always update immediately if there are unread messages to maintain accurate read/unread state. + # Visiting a conversation should clear any unread inbox notifications for this conversation. + Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform + return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any? + return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any? + + # No unread messages - apply throttling to limit DB writes + return unless should_update_last_seen? + update_last_seen_on_conversation(DateTime.now.utc, assignee?) end @@ -142,12 +156,25 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def update_last_seen_on_conversation(last_seen_at, update_assignee) + updates = { agent_last_seen_at: last_seen_at } + updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present? + # rubocop:disable Rails/SkipsModelValidations - @conversation.update_column(:agent_last_seen_at, last_seen_at) - @conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present? + @conversation.update_columns(updates) # rubocop:enable Rails/SkipsModelValidations end + def should_update_last_seen? + # Update if at least one relevant timestamp is older than 1 hour or not set + # This prevents redundant DB writes when agents repeatedly view the same conversation + agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago + return agent_needs_update unless assignee? + + # For assignees, check both timestamps - update if either is old + assignee_needs_update = @conversation.assignee_last_seen_at.blank? || @conversation.assignee_last_seen_at < 1.hour.ago + agent_needs_update || assignee_needs_update + end + def set_conversation_status @conversation.status = params[:status] @conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until] 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/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 322c7c7fe..c7d3e2737 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -4,8 +4,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :validate_limit, only: [:create] # we are already handling the authorization in fetch inbox - before_action :check_authorization, except: [:show, :health] - before_action :validate_whatsapp_cloud_channel, only: [:health] + before_action :check_authorization, except: [:show] + + include Api::V1::Accounts::Concerns::WhatsappHealthManagement def index @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) @@ -65,28 +66,17 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController head :ok end + def reset_secret + return head :not_found unless @inbox.api? + + @inbox.channel.reset_secret! + end + def destroy ::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present? 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 - private def fetch_inbox @@ -98,12 +88,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]) @@ -200,18 +184,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 8eb24b757..972b244fa 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -79,7 +79,7 @@ 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: [] }] } + :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] } ) end diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb index 479d8ae1b..bf20bc6ff 100644 --- a/app/controllers/api/v1/accounts/upload_controller.rb +++ b/app/controllers/api/v1/accounts/upload_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController elsif params[:external_url].present? create_from_url else - render_error('No file or URL provided', :unprocessable_entity) + render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity) end render_success(result) if result.is_a?(ActiveStorage::Blob) @@ -19,35 +19,21 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController end def create_from_url - uri = parse_uri(params[:external_url]) - return if performed? - - fetch_and_process_file_from_uri(uri) - end - - def parse_uri(url) - uri = URI.parse(url) - validate_uri(uri) - uri - rescue URI::InvalidURIError, SocketError - render_error('Invalid URL provided', :unprocessable_entity) - nil - end - - def validate_uri(uri) - raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) - end - - def fetch_and_process_file_from_uri(uri) - uri.open do |file| - create_and_save_blob(file, File.basename(uri.path), file.content_type) + SafeFetch.fetch(params[:external_url].to_s) do |result| + create_and_save_blob(result.tempfile, result.filename, result.content_type) end - rescue OpenURI::HTTPError => e - render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity) - rescue SocketError - render_error('Invalid URL provided', :unprocessable_entity) + rescue SafeFetch::HttpError => e + render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity) + rescue SafeFetch::FetchError + render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity) + rescue SafeFetch::FileTooLargeError + render_error(I18n.t('errors.upload.file_too_large'), :unprocessable_entity) + rescue SafeFetch::UnsupportedContentTypeError + render_error(I18n.t('errors.upload.unsupported_content_type'), :unprocessable_entity) + rescue SafeFetch::Error + render_error(I18n.t('errors.upload.invalid_url'), :unprocessable_entity) rescue StandardError - render_error('An unexpected error occurred', :internal_server_error) + render_error(I18n.t('errors.upload.unexpected'), :internal_server_error) end def create_and_save_blob(io, filename, content_type) diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 57062a5b2..7176d6e1b 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -30,9 +30,20 @@ class Api::V1::AccountsController < Api::BaseController locale: account_params[:locale], user: current_user ).perform + enqueue_branding_enrichment if @user - send_auth_headers(@user) - render 'api/v1/accounts/create', format: :json, locals: { resource: @user } + # Authenticated users (dashboard "add account") and api_only signups + # need the full response with account_id. API-only deployments have no + # frontend to handle the email confirmation flow, so they need auth + # tokens to proceed. + # Unauthenticated web signup returns only the email — no session is + # created until the user confirms via the email link. + if current_user || api_only_signup? + send_auth_headers(@user) + render 'api/v1/accounts/create', format: :json, locals: { resource: @user } + else + render json: { email: @user.email } + end else render_error_response(CustomExceptions::Account::SignupFailed.new({})) end @@ -59,6 +70,16 @@ class Api::V1::AccountsController < Api::BaseController private + def enqueue_branding_enrichment + return if account_params[:email].blank? + + Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email]) + Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30) + rescue StandardError => e + # Enrichment is optional — never let queue/Redis failures abort signup + ChatwootExceptionTracker.new(e).capture_exception + end + def ensure_account_name # ensure that account_name and user_full_name is present # this is becuase the account builder and the models validations are not triggered @@ -92,12 +113,24 @@ class Api::V1::AccountsController < Api::BaseController end def settings_params - params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label, - conversation_required_attributes: []) + params.permit(*permitted_settings_attributes) + end + + def permitted_settings_attributes + [:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label] 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 api_only_signup? + # CW_API_ONLY_SERVER is the canonical flag for API-only deployments. + # ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose. + # Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get + # typecasts it to boolean, coercing 'api_only' to true. + ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) || + InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only' end def validate_captcha @@ -112,3 +145,5 @@ class Api::V1::AccountsController < Api::BaseController } end end + +Api::V1::AccountsController.prepend_mod_with('Api::V1::AccountsSettings') diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index 5138fe675..6c595ab59 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/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/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index a51b4c2d6..83b3dc8b1 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -43,7 +43,15 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController end def set_conversation - @conversation = create_conversation if conversation.nil? + return unless conversation.nil? + + @conversation = create_conversation + apply_labels if permitted_params[:labels].present? + end + + def apply_labels + valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title) + @conversation.update_labels(valid_labels) if valid_labels.present? end def message_finder_params @@ -64,7 +72,14 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def permitted_params # timestamp parameter is used in create conversation method - params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to]) + # custom_attributes and labels are applied when a new conversation is created alongside the first message + params.permit( + :id, :before, :after, :website_token, + contact: [:name, :email], + message: [:content, :referer_url, :timestamp, :echo_id, :reply_to], + custom_attributes: {}, + labels: [] + ) end def set_message diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 714aeb0c9..192b3619c 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -62,6 +62,31 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render json: bot_metrics end + def inbox_label_matrix + builder = V2::Reports::InboxLabelMatrixBuilder.new( + account: Current.account, + params: inbox_label_matrix_params + ) + 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) @@ -139,4 +164,28 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController def conversation_metrics V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics end + + def inbox_label_matrix_params + { + since: params[:since], + until: params[:until], + inbox_ids: params[:inbox_ids], + 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/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb index 98b3f05d7..40d5947b9 100644 --- a/app/controllers/api/v2/accounts/summary_reports_controller.rb +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel] def agent - render_report_with(V2::Reports::AgentSummaryBuilder) + render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent) end def team - render_report_with(V2::Reports::TeamSummaryBuilder) + render_report_with(V2::Reports::TeamSummaryBuilder, type: :team) end def inbox - render_report_with(V2::Reports::InboxSummaryBuilder) + render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox) end def label @@ -38,8 +38,9 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr } end - def render_report_with(builder_class) - builder = builder_class.new(account: Current.account, params: @builder_params) + def render_report_with(builder_class, type: nil) + builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params + builder = builder_class.new(account: Current.account, params: builder_params) render json: builder.build 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/auth/resend_confirmations_controller.rb b/app/controllers/auth/resend_confirmations_controller.rb new file mode 100644 index 000000000..b2c778c46 --- /dev/null +++ b/app/controllers/auth/resend_confirmations_controller.rb @@ -0,0 +1,18 @@ +# Unauthenticated endpoint for resending confirmation emails during signup. +# This is a standalone controller (not on DeviseOverrides::ConfirmationsController) +# because OmniAuth middleware intercepts all POST /auth/* routes as provider +# callbacks, and Devise controller filters cause 307 redirects for custom actions. +# Inherits from ActionController::API to avoid both issues entirely. +# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha. +class Auth::ResendConfirmationsController < ActionController::API + def create + return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid? + + email = params[:email] + return head(:ok) unless email.is_a?(String) + + user = User.from_email(email.strip.downcase) + user&.send_confirmation_instructions unless user&.confirmed? + head :ok + 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/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index 900125670..2c8387142 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa private def sign_in_user + # Capture before skip_confirmation! sets confirmed_at, which would + # make oauth_user_needs_password_reset? return false and skip the + # password reset for persisted unconfirmed users. + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def sign_in_user_on_mobile + # See comment in sign_in_user for why this is captured before skip_confirmation! + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? create_account_for_user + set_random_password_if_oauth_user token = @resource.send(:set_reset_password_token) frontend_url = ENV.fetch('FRONTEND_URL', nil) redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" @@ -51,8 +60,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) @@ -82,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) end + def oauth_user_needs_password_reset? + @resource.present? && (@resource.new_record? || !@resource.confirmed?) + end + + def set_random_password_if_oauth_user + # Password must satisfy secure_password requirements (uppercase, lowercase, number, special char) + @resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted? + end + def default_devise_mapping 'user' 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/health_controller.rb b/app/controllers/health_controller.rb new file mode 100644 index 000000000..fdf969a39 --- /dev/null +++ b/app/controllers/health_controller.rb @@ -0,0 +1,7 @@ +# Inherits from ActionController::Base to skip all middleware, +# authentication, and callbacks. Used for health checks +class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController + def show + render json: { status: 'woot' } + end +end 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/platform/api/v1/email_channel_migrations_controller.rb b/app/controllers/platform/api/v1/email_channel_migrations_controller.rb new file mode 100644 index 000000000..3e9e8defd --- /dev/null +++ b/app/controllers/platform/api/v1/email_channel_migrations_controller.rb @@ -0,0 +1,101 @@ +class Platform::Api::V1::EmailChannelMigrationsController < PlatformController + before_action :set_account + before_action :validate_account_permissible + before_action :validate_feature_flag + before_action :validate_params + + def create + results = migrate_email_channels + render json: { results: results }, status: :ok + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def validate_account_permissible + return if @platform_app.platform_app_permissibles.find_by(permissible: @account) + + render json: { error: 'Non permissible resource' }, status: :unauthorized + end + + def validate_feature_flag + return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false)) + + render json: { error: 'Email channel migration is not enabled' }, status: :forbidden + end + + def validate_params + return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank? + + return unless migration_params.size > MAX_MIGRATIONS + + return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" }, + status: :unprocessable_entity + end + + def migrate_email_channels + migration_params.map { |entry| migrate_single(entry) } + end + + MAX_MIGRATIONS = 25 + SUPPORTED_PROVIDERS = %w[google microsoft].freeze + + def migrate_single(entry) + validate_provider!(entry[:provider]) + + ActiveRecord::Base.transaction do + channel = create_channel(entry) + inbox = create_inbox(channel, entry) + + { email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' } + end + rescue StandardError => e + { email: entry[:email], status: 'error', message: e.message } + end + + def create_channel(entry) + Channel::Email.create!( + account_id: @account.id, + email: entry[:email], + provider: entry[:provider], + provider_config: entry[:provider_config]&.to_h, + imap_enabled: entry.fetch(:imap_enabled, true), + imap_address: entry[:imap_address] || default_imap_address(entry[:provider]), + imap_port: entry[:imap_port] || 993, + imap_login: entry[:imap_login] || entry[:email], + imap_enable_ssl: entry.fetch(:imap_enable_ssl, true) + ) + end + + def create_inbox(channel, entry) + @account.inboxes.create!( + name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}", + channel: channel + ) + end + + def validate_provider!(provider) + return if SUPPORTED_PROVIDERS.include?(provider) + + raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}" + end + + def default_imap_address(provider) + case provider + when 'google' then 'imap.gmail.com' + when 'microsoft' then 'outlook.office365.com' + else '' + end + end + + def migration_params + params.permit(migrations: [ + :email, :provider, :inbox_name, + :imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl, + { provider_config: {} } + ])[:migrations] + 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 e6cc2aa69..2bbfafcc7 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -1,11 +1,13 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show, :index] before_action :portal + before_action :ensure_portal_feature_enabled before_action :set_category, except: [:index, :show, :tracking_pixel] before_action :set_article, only: [:show] 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? @@ -60,7 +62,7 @@ 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) + @parsed_content = render_article_content(@article.content.to_s) end def set_category @@ -73,7 +75,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/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index ebfcb310a..3fb200269 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -1,6 +1,7 @@ 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 :ensure_portal_feature_enabled before_action :set_category, only: [:show] layout 'portal' diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index df4552432..a187ca8a8 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -1,7 +1,8 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show] - before_action :portal before_action :redirect_to_portal_with_locale, only: [:show] + before_action :portal + before_action :ensure_portal_feature_enabled layout 'portal' def show @@ -24,6 +25,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl def redirect_to_portal_with_locale return if params[:locale].present? + portal redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" end end diff --git a/app/controllers/public_controller.rb b/app/controllers/public_controller.rb index 3b83a2210..b266b725b 100644 --- a/app/controllers/public_controller.rb +++ b/app/controllers/public_controller.rb @@ -18,4 +18,11 @@ class PublicController < ActionController::Base Please send us an email at support@chatwoot.com with the custom domain name and account API key" }, status: :unauthorized and return end + + def ensure_portal_feature_enabled + return unless ChatwootApp.chatwoot_cloud? + return if @portal.account.feature_enabled?('help_center') + + render 'public/api/v1/portals/not_active', status: :payment_required + end end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index e9d27c67a..67d58aef1 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -42,11 +42,11 @@ 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], - 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET], + 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN], @@ -55,7 +55,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController @allowed_configs = mapping.fetch( @config, - %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE] + %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY] ) end end diff --git a/app/controllers/super_admin/push_diagnostics_controller.rb b/app/controllers/super_admin/push_diagnostics_controller.rb new file mode 100644 index 000000000..c33cfdc1e --- /dev/null +++ b/app/controllers/super_admin/push_diagnostics_controller.rb @@ -0,0 +1,68 @@ +class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController + def show + @query = params[:user_query].to_s.strip + @user = resolve_user(@query) + @subscriptions = @user ? @user.notification_subscriptions.order(:id) : [] + @results = [] + end + + def create + @user = User.find_by(id: params[:user_id]) + return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil? + + ids = parsed_subscription_ids + if ids.empty? + return redirect_to super_admin_push_diagnostics_path(user_query: @user.id), + alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test') + end + + run_test_and_render(ids) + end + + def destroy_subscriptions + user = User.find_by(id: params[:user_id]) + return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil? + + ids = parsed_subscription_ids + if ids.empty? + return redirect_to super_admin_push_diagnostics_path(user_query: user.id), + alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete') + end + + deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size + log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}") + redirect_to super_admin_push_diagnostics_path(user_query: user.id), + notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count) + end + + private + + def run_test_and_render(ids) + @query = @user.id.to_s + @subscriptions = @user.notification_subscriptions.order(:id) + @results = Notification::PushTestService.new( + user: @user, subscription_ids: ids, + title: params[:push_title], body: params[:push_body] + ).perform + + log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}") + render :show + end + + def log_super_admin_action(message) + Rails.logger.info( + "[SuperAdmin] push diagnostics #{message} " \ + "(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})" + ) + end + + def resolve_user(query) + return if query.blank? + + query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query) + end + + def parsed_subscription_ids + Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i) + end +end 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/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 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/helpers/filters/filter_helper.rb b/app/helpers/filters/filter_helper.rb index fe03dae28..4f345676e 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 b016cc9d9..d4a05f054 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 a63cb1a90..a706e2df5 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -98,7 +98,9 @@ export default { mql.onchange = e => setColorTheme(e.matches); }, setLocale(locale) { - this.$root.$i18n.locale = locale; + if (locale) { + this.$root.$i18n.locale = locale; + } }, async initializeAccount() { await this.$store.dispatch('accounts/get'); @@ -131,7 +133,7 @@ export default {
@@ -164,10 +166,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/agentBots.js b/app/javascript/dashboard/api/agentBots.js index de887f415..a16b252de 100644 --- a/app/javascript/dashboard/api/agentBots.js +++ b/app/javascript/dashboard/api/agentBots.js @@ -25,6 +25,10 @@ class AgentBotsAPI extends ApiClient { resetAccessToken(botId) { return axios.post(`${this.url}/${botId}/reset_access_token`); } + + resetSecret(botId) { + return axios.post(`${this.url}/${botId}/reset_secret`); + } } export default new AgentBotsAPI(); diff --git a/app/javascript/dashboard/api/captain/customTools.js b/app/javascript/dashboard/api/captain/customTools.js index d0818d941..471c2846b 100644 --- a/app/javascript/dashboard/api/captain/customTools.js +++ b/app/javascript/dashboard/api/captain/customTools.js @@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient { delete(id) { return axios.delete(`${this.url}/${id}`); } + + test(data = {}) { + return axios.post(`${this.url}/test`, { + custom_tool: data, + }); + } } export default new CaptainCustomTools(); 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 83ba3e9ba..cc564fe96 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -42,6 +42,16 @@ 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, + }); + } + + resetSecret(inboxId) { + return axios.post(`${this.url}/${inboxId}/reset_secret`); + } } 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/images/auth/signup-bg.jpg b/app/javascript/dashboard/assets/images/auth/signup-bg.jpg new file mode 100644 index 000000000..884a57922 Binary files /dev/null and b/app/javascript/dashboard/assets/images/auth/signup-bg.jpg differ diff --git a/app/javascript/dashboard/assets/scss/_base.scss b/app/javascript/dashboard/assets/scss/_base.scss index 14ae2a9d4..108da3889 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; } @@ -106,6 +106,10 @@ select { &[disabled] { @apply field-disabled; } + + option:not(:disabled) { + @apply bg-n-solid-2 text-n-slate-12; + } } // Textarea @@ -126,13 +130,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 +147,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/_next-colors.scss b/app/javascript/dashboard/assets/scss/_next-colors.scss index 4d7975a32..784edce6c 100644 --- a/app/javascript/dashboard/assets/scss/_next-colors.scss +++ b/app/javascript/dashboard/assets/scss/_next-colors.scss @@ -107,21 +107,39 @@ --violet-11: 101 85 183; --violet-12: 47 38 95; - --background-color: 253 253 253; - --text-blue: 8 109 224; + --background-color: 247 247 247; + --surface-1: 254 254 254; + --surface-2: 255 255 255; + --surface-active: 255 255 255; + --background-input-box: 0, 0, 0, 0.03; + --text-blue: 1 22 44; + --text-purple: 2 4 49; + --text-amber: 37 24 1; --border-container: 236 236 236; - --border-strong: 235 235 235; + --border-strong: 226 227 231; --border-weak: 234 234 234; + --border-blue-strong: 18 61 117; --solid-1: 255 255 255; --solid-2: 255 255 255; --solid-3: 255 255 255; --solid-active: 255 255 255; - --solid-amber: 252 232 193; + --solid-amber: 255 228 181; --solid-blue: 218 236 255; + --solid-blue-2: 251 253 255; --solid-iris: 230 231 255; + --solid-purple: 230 231 255; + --solid-red: 254 200 201; + --solid-amber-button: 255 221 141; + --card-color: 255 255 255; + --overlay: 0, 0, 0, 0.12; + --overlay-avatar: 255, 255, 255, 0.67; + --button-color: 255 255 255; + --button-hover-color: 255, 255, 255, 0.2; + --label-background: 247 247 247; + --label-border: 0, 0, 0, 0.04; - --alpha-1: 67, 67, 67, 0.06; - --alpha-2: 201, 202, 207, 0.15; + --alpha-1: 215, 215, 215, 0.22; + --alpha-2: 196, 197, 198, 0.22; --alpha-3: 255, 255, 255, 0.96; --black-alpha-1: 0, 0, 0, 0.12; --black-alpha-2: 0, 0, 0, 0.04; @@ -235,25 +253,43 @@ --violet-11: 169 153 236; --violet-12: 226 221 254; - --background-color: 18 18 19; - --border-strong: 52 52 52; - --border-weak: 38 38 42; + --background-color: 28 29 32; + --surface-1: 20 21 23; + --surface-2: 22 23 26; + --surface-active: 53 57 66; + --background-input-box: 255, 255, 255, 0.02; + --text-blue: 213 234 255; + --text-purple: 232 233 254; + --text-amber: 255 247 234; + --border-strong: 46 45 50; + --border-weak: 31 31 37; + --border-blue-strong: 201 226 255; --solid-1: 23 23 26; --solid-2: 29 30 36; --solid-3: 44 45 54; --solid-active: 53 57 66; - --solid-amber: 42 37 30; - --solid-blue: 16 49 91; + --solid-amber: 56 50 41; + --solid-blue: 15 57 102; + --solid-blue-2: 26 29 35; --solid-iris: 38 42 101; - --text-blue: 126 182 255; + --solid-purple: 51 51 107; + --solid-red: 90 33 34; + --solid-amber-button: 255 221 141; + --card-color: 28 30 34; + --overlay: 0, 0, 0, 0.4; + --overlay-avatar: 0, 0, 0, 0.05; + --button-color: 42 43 51; + --button-hover-color: 0, 0, 0, 0.15; + --label-background: 36 38 45; + --label-border: 255, 255, 255, 0.03; - --alpha-1: 36, 36, 36, 0.8; - --alpha-2: 139, 147, 182, 0.15; - --alpha-3: 36, 38, 45, 0.9; + --alpha-1: 35, 36, 42, 0.8; + --alpha-2: 147, 153, 176, 0.12; + --alpha-3: 33, 34, 38, 0.95; --black-alpha-1: 0, 0, 0, 0.3; --black-alpha-2: 0, 0, 0, 0.2; --border-blue: 39, 129, 246, 0.5; - --border-container: 236, 236, 236, 0; + --border-container: 255, 255, 255, 0; --white-alpha: 255, 255, 255, 0.1; } } 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 |

-

+

{{ description }}

diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue index 1e477eafe..eb94d1ddd 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue @@ -20,7 +20,7 @@ const handleClick = () => {
-

{{ title }}

+

{{ title }}

-

{{ description }}

+

{{ description }}

  • [] }, isFetchingInboxes: { type: Boolean, default: false }, }); @@ -61,39 +60,19 @@ const handleFetchInboxes = () => {
    -

    +

    {{ name }}

    -
    -
    - - {{ - enabled - ? t( - 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE' - ) - : t( - 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE' - ) - }} - -
    - -
    +
    -

    +

    {{ description }}

    - + {{ `${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:` }} {{ order }}
    - + {{ `${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:` }} diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue index aeea0cbdd..31418919a 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/DataTable.vue @@ -19,11 +19,15 @@ defineProps({ }, }); -const emit = defineEmits(['delete']); +const emit = defineEmits(['delete', 'navigate']); const handleDelete = itemId => { emit('delete', itemId); }; + +const handleNavigate = item => { + emit('navigate', item); +};