diff --git a/.circleci/config.yml b/.circleci/config.yml index f764cb611..c533e1402 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -77,7 +77,8 @@ jobs: - node/install: node-version: '24.13' - - node/install-pnpm + - node/install-pnpm: + version: '10.2.0' - node/install-packages: pkg-manager: pnpm override-ci-command: pnpm i @@ -118,7 +119,8 @@ jobs: - checkout - node/install: node-version: '24.13' - - node/install-pnpm + - node/install-pnpm: + version: '10.2.0' - node/install-packages: pkg-manager: pnpm override-ci-command: pnpm i @@ -144,12 +146,13 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 20 + parallelism: 18 steps: - checkout - node/install: node-version: '24.13' - - node/install-pnpm + - node/install-pnpm: + version: '10.2.0' - node/install-packages: pkg-manager: pnpm override-ci-command: pnpm i diff --git a/.env.example b/.env.example index 69b1b9cde..c9f3c855c 100644 --- a/.env.example +++ b/.env.example @@ -272,9 +272,9 @@ AZURE_APP_SECRET= # ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false -# AI powered features -## OpenAI key -# OPENAI_API_KEY= +# AI powered features (Captain) +# The OpenAI API key and endpoint for Captain are not configured via .env. +# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT). # Housekeeping/Performance related configurations # Set to true if you want to remove stale contact inboxes diff --git a/AGENTS.md b/AGENTS.md index 2ab6373b7..82acfcc25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,13 +43,18 @@ ## General Guidelines -- MVP focus: Least code change, happy-path only -- No unnecessary defensive programming -- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate +- Prefer the smallest production-ready change that solves the current problem. +- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary. +- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior. +- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks. +- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully. +- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing. +- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read. - Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness - Break down complex tasks into small, testable units - Iterate after confirmation - Avoid writing specs unless explicitly asked +- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity. - Remove dead/unreachable/unused code - Don’t write multiple versions or backups for the same logic — pick the best approach and implement it - Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs @@ -80,8 +85,8 @@ ## Project-Specific - **Translations**: - - Only update `en.yml` and `en.json` - - Other languages are handled by the community + - For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community + - Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files - Backend i18n → `en.yml`, Frontend i18n → `en.json` - **Frontend**: - Use `components-next/` for message bubbles (the rest is being deprecated) diff --git a/Gemfile b/Gemfile index 7533cf3cf..7735dc099 100644 --- a/Gemfile +++ b/Gemfile @@ -195,7 +195,7 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' -gem 'ai-agents', '>= 0.10.0' +gem 'ai-agents', '>= 0.12.0' # TODO: Move this gem as a dependency of ai-agents gem 'ruby_llm', '>= 1.14.1' diff --git a/Gemfile.lock b/Gemfile.lock index 8d6132849..46a1c0449 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,7 +126,7 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) - ai-agents (0.10.0) + ai-agents (0.12.0) ruby_llm (~> 1.14) annotaterb (4.20.0) activerecord (>= 6.0.0) @@ -170,7 +170,7 @@ GEM base64 (0.3.0) bcrypt (3.1.22) benchmark (0.4.1) - bigdecimal (3.3.1) + bigdecimal (4.1.2) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) @@ -193,12 +193,12 @@ GEM climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) - concurrent-ruby (1.3.5) + concurrent-ruby (1.3.7) connection_pool (2.5.5) crack (1.0.0) bigdecimal rexml - crass (1.0.6) + crass (1.0.7) cronex (0.15.0) tzinfo unicode (>= 0.4.4.5) @@ -211,13 +211,14 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - datadog (2.19.0) - datadog-ruby_core_source (~> 3.4, >= 3.4.1) - libdatadog (~> 18.1.0.1.0) - libddwaf (~> 1.24.1.0.3) + datadog (2.38.0) + cgi + datadog-ruby_core_source (~> 3.5, >= 3.5.3) + libdatadog (~> 36.0.0.1.0) + libddwaf (~> 1.30.0.0.0) logger msgpack - datadog-ruby_core_source (3.4.1) + datadog-ruby_core_source (3.5.3) date (3.5.1) debug (1.8.0) irb (>= 1.5.0) @@ -274,8 +275,8 @@ GEM dry-logic (~> 1.5) dry-types (~> 1.8) zeitwerk (~> 2.6) - dry-types (1.8.3) - bigdecimal (~> 3.0) + dry-types (1.9.1) + bigdecimal (>= 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) @@ -304,7 +305,7 @@ GEM railties (>= 5.0.0) faker (3.2.0) i18n (>= 1.8.11, < 2) - faraday (2.14.2) + faraday (2.14.3) faraday-net_http (>= 2.0, < 3.5) json logger @@ -474,7 +475,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.19.8) + json (2.19.9) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -522,15 +523,16 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - libdatadog (18.1.0.1.0) - libdatadog (18.1.0.1.0-x86_64-linux) - libddwaf (1.24.1.0.3) + libdatadog (36.0.0.1.0) + libdatadog (36.0.0.1.0-arm64-darwin) + libdatadog (36.0.0.1.0-x86_64-linux) + libddwaf (1.30.0.0.2) ffi (~> 1.0) - libddwaf (1.24.1.0.3-arm64-darwin) + libddwaf (1.30.0.0.2-arm64-darwin) ffi (~> 1.0) - libddwaf (1.24.1.0.3-x86_64-darwin) + libddwaf (1.30.0.0.2-x86_64-darwin) ffi (~> 1.0) - libddwaf (1.24.1.0.3-x86_64-linux) + libddwaf (1.30.0.0.2-x86_64-linux) ffi (~> 1.0) line-bot-api (1.28.0) lint_roller (1.1.0) @@ -547,7 +549,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.23.1) + loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -570,7 +572,7 @@ GEM minitest (5.25.5) mock_redis (0.36.0) ruby2_keywords - msgpack (1.8.0) + msgpack (1.8.3) multi_json (1.15.0) multi_xml (0.9.1) bigdecimal (>= 3.1, < 5) @@ -598,14 +600,14 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.7.5) - nokogiri (1.19.3) + nokogiri (1.19.4) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.19.3-arm64-darwin) + nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.3-x86_64-darwin) + nokogiri (1.19.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.3-x86_64-linux-gnu) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.6) auth-sanitizer (~> 0.2, >= 0.2.1) @@ -627,7 +629,7 @@ GEM rack (>= 1.2, < 4) snaky_hash (~> 2.0, >= 2.0.5) version_gem (~> 1.1, >= 1.1.11) - oj (3.16.10) + oj (3.17.3) bigdecimal (>= 3.0) ostruct (>= 0.2) omniauth (2.1.4) @@ -674,7 +676,7 @@ GEM opentelemetry-api (~> 1.0) orm_adapter (0.5.0) os (1.1.4) - ostruct (0.6.1) + ostruct (0.6.3) parallel (1.27.0) parser (3.3.8.0) ast (~> 2.4.1) @@ -740,8 +742,8 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.1) - loofah (~> 2.21) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (7.0.10) i18n (>= 0.7, < 2) @@ -1030,7 +1032,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket-driver (0.7.7) + websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -1058,7 +1060,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) - ai-agents (>= 0.10.0) + ai-agents (>= 0.12.0) annotaterb attr_extras audited (~> 5.4, >= 5.4.1) diff --git a/LICENSE b/LICENSE index 8f80f35be..d0425a707 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2017-2024 Chatwoot Inc. +Copyright (c) 2017-2026 Chatwoot Inc. Portions of this software are licensed as follows: diff --git a/VERSION_CW b/VERSION_CW index fb0557132..ecbc3b030 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.15.1 +4.16.0 diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 24b6d9e70..c7608399e 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -91,15 +91,17 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder def fallback_params(attachment) { - fallback_title: attachment['title'], + fallback_title: attachment['title'] || attachment.dig('payload', 'title'), external_url: attachment['url'] || attachment.dig('payload', 'url') } end # Facebook shared posts point to page URLs, not downloadable media URLs. + # Both `share` and `post` attachment types carry a page URL rather than a media file, + # so map them to `fallback` (which keeps the title/link without attempting a download). # Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling. def normalize_file_type(type) - return :fallback if type.to_sym == :share + return :fallback if [:share, :post].include?(type.to_sym) super end diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index ecd6f06ea..712a24608 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -6,6 +6,11 @@ class Messages::Messenger::MessageBuilder return if unsupported_file_type?(attachment['type']) params = attachment_params(attachment) + # During Meta's sticker webhook transition, a sticker message carries both an `image` + # and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it + # isn't attached twice, while still storing legitimate duplicate attachments of other types. + return if duplicate_sticker?(attachment, params[:external_url]) + attachment_obj = @message.attachments.new(params.except(:remote_file_url)) attachment_obj.save! if facebook_reel?(attachment) @@ -13,10 +18,14 @@ class Messages::Messenger::MessageBuilder elsif params[:remote_file_url] attach_file(attachment_obj, params[:remote_file_url]) end + fetch_attachment_links(attachment_obj) + update_attachment_file_type(attachment_obj) + end + + def fetch_attachment_links(attachment_obj) 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' - update_attachment_file_type(attachment_obj) end def attach_file(attachment, file_url) @@ -111,13 +120,20 @@ class Messages::Messenger::MessageBuilder # 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 + FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze def normalize_file_type(type) sym = type.to_sym FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym) end + def duplicate_sticker?(attachment, url) + return false unless attachment['type'].to_sym == :sticker + return false if url.blank? + + @message.attachments.any? { |existing| existing.external_url == url } + 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) diff --git a/app/builders/v2/reports/drilldown_builder.rb b/app/builders/v2/reports/drilldown_builder.rb new file mode 100644 index 000000000..a3d2c073d --- /dev/null +++ b/app/builders/v2/reports/drilldown_builder.rb @@ -0,0 +1,213 @@ +class V2::Reports::DrilldownBuilder + include DateRangeHelper + include TimezoneHelper + + DEFAULT_GROUP_BY = 'day'.freeze + DEFAULT_PAGE = 1 + DEFAULT_PER_PAGE = 25 + MAX_PER_PAGE = 100 + SUPPORTED_GROUP_BY = %w[hour day week month year].freeze + SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze + MESSAGE_METRICS = { + 'incoming_messages_count' => :incoming, + 'outgoing_messages_count' => :outgoing + }.freeze + MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze + + pattr_initialize :account, :params + + def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s) + + def build + records = paginated_records.to_a + { meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } } + end + + private + + def meta + { + metric: metric, + record_type: record_type, + bucket: { + since: bucket_range.begin.to_i, + until: bucket_range.end.to_i + }, + current_page: current_page, + per_page: per_page, + total_count: paginated_records.total_count, + conversation_count: conversation_count + } + end + + def conversation_count + return paginated_records.total_count if conversation_metric? + + drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id) + end + + def paginated_records + @paginated_records ||= drilldown_scope.page(current_page).per(per_page) + end + + def drilldown_scope + if message_metric? + message_scope + elsif conversation_metric? + conversation_scope + else + reporting_event_scope + end + end + + def message_scope + scope.messages + .where(account_id: account.id, created_at: bucket_range) + .public_send(MESSAGE_METRICS.fetch(metric)) + .includes(:sender, conversation: [:assignee, :contact, :inbox]) + .reorder(created_at: :desc) + end + + def conversation_scope + scope.conversations + .where(account_id: account.id, created_at: bucket_range) + .includes(:assignee, :contact, :inbox) + .order(created_at: :desc) + end + + def reporting_event_scope + events = scope.reporting_events + .where(account_id: account.id, name: raw_event_name, created_at: bucket_range) + .includes(:user, :inbox, conversation: [:assignee, :contact, :inbox]) + .order(created_at: :desc) + + if raw_count_strategy == :exclude_bot_handoffs + events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery) + elsif raw_count_strategy == :distinct_conversation + events = events.where(id: distinct_conversation_event_ids(events)) + end + + events + end + + def bot_handoff_conversation_ids_subquery + scope.reporting_events + .where(account_id: account.id, name: :conversation_bot_handoff, created_at: range) + .where.not(conversation_id: nil) + .select(:conversation_id) + end + + def distinct_conversation_event_ids(events) + events.reorder(nil) + .where.not(conversation_id: nil) + .select('MAX(reporting_events.id)') + .group(:conversation_id) + end + + def record_serializer(records) + @record_serializer ||= V2::Reports::DrilldownRecordSerializer.new( + account, + metric, + use_business_hours?, + records + ) + end + + def bucket_range + @bucket_range ||= begin + bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone) + bucket_end = bucket_end_for(bucket_start) + requested_start = Time.zone.at(params[:since].to_i) + requested_end = Time.zone.at(params[:until].to_i) + + [bucket_start, requested_start].max...[bucket_end, requested_end].min + end + end + + def bucket_end_for(bucket_start) + { + 'hour' => bucket_start + 1.hour, + 'day' => bucket_start + 1.day, + 'week' => bucket_start + 1.week, + 'month' => bucket_start + 1.month, + 'year' => bucket_start + 1.year + }.fetch(group_by) + end + + def scope + case dimension_type + when 'account' then account + when 'inbox' then inbox + when 'agent' then user + when 'label' then label + when 'team' then team + else + raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}" + end + end + + def inbox = @inbox ||= account.inboxes.find(params[:id]) + + def user = @user ||= account.users.find(params[:id]) + + def label = @label ||= account.labels.find(params[:id]) + + def team = @team ||= account.teams.find(params[:id]) + + def metric + params[:metric].to_s + end + + def report_metric + @report_metric ||= Reports::ReportMetricRegistry.fetch(metric) + end + + def raw_event_name + report_metric&.raw_event_name + end + + def raw_count_strategy + report_metric&.raw_count_strategy + end + + def record_type + return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric) + + 'conversation' + end + + def message_metric? + MESSAGE_METRICS.key?(metric) + end + + def conversation_metric? + metric == 'conversations_count' + end + + def dimension_type + (params[:type].presence || 'account').to_s + end + + def group_by + @group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY + end + + def timezone + @timezone ||= timezone_name_from_offset(params[:timezone_offset]) + end + + def current_page + [params[:page].to_i, DEFAULT_PAGE].max + end + + def per_page + requested_per_page = params[:per_page].to_i + requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0 + + [requested_per_page, MAX_PER_PAGE].min + end + + def use_business_hours? + ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + end +end diff --git a/app/builders/v2/reports/drilldown_record_serializer.rb b/app/builders/v2/reports/drilldown_record_serializer.rb new file mode 100644 index 000000000..04edf65b4 --- /dev/null +++ b/app/builders/v2/reports/drilldown_record_serializer.rb @@ -0,0 +1,199 @@ +class V2::Reports::DrilldownRecordSerializer + MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze + + attr_reader :account, :metric, :use_business_hours, :records + + def initialize(account, metric, use_business_hours, records = []) + @account = account + @metric = metric + @use_business_hours = use_business_hours + @records = records + end + + def serialize(record) + return serialize_message(record) if record.is_a?(Message) + return serialize_conversation_event(record) if record.is_a?(ReportingEvent) + + serialize_conversation(record) + end + + private + + def serialize_message(message, metric_value: nil, occurred_at: nil) + { + record_type: 'message', + conversation: conversation_attributes(message.conversation), + message: message_attributes(message), + metric_value: metric_value, + occurred_at: (occurred_at || message.created_at).to_i + } + end + + def serialize_conversation_event(event) + inferred_message = inferred_message_for(event) + if inferred_message.present? + return serialize_message( + inferred_message, + metric_value: event_metric_value(event), + occurred_at: event_timestamp(event) + ) + end + + serialize_conversation( + event.conversation, + metric_value: event_metric_value(event), + occurred_at: event_timestamp(event), + event_name: event.name + ) + end + + def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil) + serialized_record = { + record_type: 'conversation', + conversation: conversation_attributes(conversation), + message: nil, + metric_value: metric_value, + occurred_at: (occurred_at || conversation&.created_at)&.to_i + } + serialized_record[:event_name] = event_name if event_name.present? + serialized_record + end + + def conversation_attributes(conversation) + return {} if conversation.blank? + + { + id: conversation.id, + display_id: conversation.display_id, + contact_id: conversation.contact_id, + contact_name: conversation.contact&.name, + inbox_id: conversation.inbox_id, + inbox_name: conversation.inbox&.name, + assignee_id: conversation.assignee_id, + assignee_name: conversation.assignee&.name, + status: conversation.status, + created_at: conversation.created_at.to_i, + last_activity_at: conversation.last_activity_at.to_i, + last_message: last_message_attributes(conversation) + } + end + + def message_attributes(message) + { + id: message.id, + content: message.content, + message_type: message.message_type, + sender_name: message.sender&.try(:name), + created_at: message.created_at.to_i + } + end + + def last_message_attributes(conversation) + message = latest_messages_by_conversation_id[conversation.id] + return if message.blank? + + message_attributes(message) + end + + def inferred_message_for(event) + return unless MESSAGE_EVENT_METRICS.include?(metric) + return if event.conversation.blank? || event.event_end_time.blank? + + inferred_messages_by_event_id[event.id] + end + + def first_response_event_with_user?(event) + metric == 'avg_first_response_time' && event.user_id.present? + end + + def message_inference_range(event) + (event.event_end_time - 1.second)..(event.event_end_time + 1.second) + end + + def event_metric_value(event) + use_business_hours ? event.value_in_business_hours : event.value + end + + def event_timestamp(event) + event.event_end_time || event.created_at + end + + def latest_messages_by_conversation_id + @latest_messages_by_conversation_id ||= if conversation_ids.blank? + {} + else + latest_messages.index_by(&:conversation_id) + end + end + + def latest_messages + Message + .where(account_id: account.id, conversation_id: conversation_ids) + .where.not(message_type: :activity) + .select('DISTINCT ON (messages.conversation_id) messages.*') + .reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC')) + .includes(:sender) + end + + def inferred_messages_by_event_id + @inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id| + messages_by_event_id[event.id] = inferred_message_candidates.find do |message| + message_matches_event?(message, event) + end + end + end + + def inferred_message_candidates + @inferred_message_candidates ||= if inference_events.blank? + [] + else + inferred_messages.to_a + end + end + + def inferred_messages + Message + .where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq) + .where(created_at: inference_time_range) + .where(message_type: %i[outgoing template]) + .includes(:sender) + .reorder(created_at: :desc, id: :desc) + end + + def message_matches_event?(message, event) + message.conversation_id == event.conversation_id && + message.created_at.between?( + message_inference_range(event).begin, + message_inference_range(event).end + ) && + message_sender_matches_event?(message, event) + end + + def message_sender_matches_event?(message, event) + return true unless first_response_event_with_user?(event) + + message.sender_id == event.user_id && message.sender_type == 'User' + end + + def inference_time_range + event_end_times = inference_events.map(&:event_end_time) + + (event_end_times.min - 1.second)..(event_end_times.max + 1.second) + end + + def inference_events + @inference_events ||= records.select do |record| + record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present? + end + end + + def conversation_ids + @conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq + end + + def conversation_id_for(record) + return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent) + + record.id + end +end diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index 5e1609b64..439ba3e31 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -30,8 +30,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController end def update - @article.update!(article_params) if params[:article].present? - render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? + persist_article_changes if params[:article].present? + render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid? end def destroy @@ -40,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController end def reorder - Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) - head :ok + positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) + render json: { positions: positions } end private @@ -67,12 +67,26 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController @portal ||= Current.account.portals.find_by!(slug: params[:portal_id]) end + # Draft-only autosaves must not bump the public-facing updated_at, so write + # them with update_columns (which skips the timestamp). update_columns also + # skips validations, so assign and validate first to avoid persisting content + # that exceeds the column length limit. + def persist_article_changes + keys = article_params.to_h.keys + if keys.any? && (keys - %w[draft_title draft_content]).empty? + @article.assign_attributes(article_params) + @article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations + else + @article.update!(article_params) + end + end + def article_params params.require(:article).permit( :title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status, - :locale, meta: [:title, - :description, - { tags: [] }] + :locale, :draft_title, :draft_content, meta: [:title, + :description, + { tags: [] }] ) end diff --git a/app/controllers/api/v1/accounts/assignable_agents_controller.rb b/app/controllers/api/v1/accounts/assignable_agents_controller.rb index a712342dd..29dcb62b2 100644 --- a/app/controllers/api/v1/accounts/assignable_agents_controller.rb +++ b/app/controllers/api/v1/accounts/assignable_agents_controller.rb @@ -2,6 +2,8 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon before_action :fetch_inboxes def index + # TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload. + @include_agent_bots = params[:include_agent_bots].present? agent_ids = @inboxes.map do |inbox| authorize inbox, :show? member_ids = inbox.members.pluck(:user_id) @@ -10,6 +12,7 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon agent_ids = agent_ids.inject(:&) agents = Current.account.users.where(id: agent_ids) @assignable_agents = (agents + Current.account.administrators).uniq + @agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : [] end private diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb index 1807d6afb..0150cb677 100644 --- a/app/controllers/api/v1/accounts/assignment_policies_controller.rb +++ b/app/controllers/api/v1/accounts/assignment_policies_controller.rb @@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC def assignment_policy_params params.require(:assignment_policy).permit( :name, :description, :assignment_order, :conversation_priority, - :fair_distribution_limit, :fair_distribution_window, :enabled + :fair_distribution_limit, :fair_distribution_window, :enabled, + :exclude_older_than_hours ) end end diff --git a/app/controllers/api/v1/accounts/base_controller.rb b/app/controllers/api/v1/accounts/base_controller.rb index e30effc59..f08b87e60 100644 --- a/app/controllers/api/v1/accounts/base_controller.rb +++ b/app/controllers/api/v1/accounts/base_controller.rb @@ -2,5 +2,14 @@ class Api::V1::Accounts::BaseController < Api::BaseController include SwitchLocale include EnsureCurrentAccountHelper before_action :current_account + before_action :validate_token_api_access, if: :authenticate_by_access_token? around_action :switch_locale_using_account_locale + + private + + def validate_token_api_access + return if Current.account.api_and_webhooks_enabled? + + render json: { error: 'API access is not enabled for this account' }, status: :forbidden + end end diff --git a/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb b/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb new file mode 100644 index 000000000..54f976b79 --- /dev/null +++ b/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb @@ -0,0 +1,28 @@ +class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController + before_action :check_admin_authorization? + + def show + set_branded_email_layout + end + + def update + unless Current.account.feature_enabled?(:branded_email_templates) + render_could_not_create_error('Branded email templates feature is not enabled') + return + end + + branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout] + EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout) + set_branded_email_layout + rescue ActiveRecord::RecordInvalid => e + render_could_not_create_error(e.record.errors.full_messages.join(', ')) + end + + private + + def set_branded_email_layout + @branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body + end +end + +Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController') diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 90cdf2418..08c0ffe43 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -6,6 +6,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController page_access_token = params[:page_access_token] page_id = params[:page_id] inbox_name = params[:inbox_name] + ActiveRecord::Base.transaction do facebook_channel = Current.account.facebook_pages.create!( page_id: page_id, user_access_token: user_access_token, @@ -15,6 +16,8 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController set_instagram_id(page_access_token, facebook_channel) set_avatar(@facebook_inbox, page_id) end + rescue CustomExceptions::Inbox::LimitExceeded => e + render_error_response(e) rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception Rails.logger.error "Error in register_facebook_page: #{e.message}" diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb index 156c031fa..482b001d6 100644 --- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas def update params_to_update = captain_params - @current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models] - @current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features] + @current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models) + @current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features) @current_account.save! render json: preferences_payload @@ -38,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas def merged_captain_models existing_models = @current_account.captain_models || {} - existing_models.merge(permitted_captain_models) + existing_models.merge(permitted_captain_models).compact_blank.presence end def merged_captain_features @@ -47,30 +47,38 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas end def permitted_captain_models - params.require(:captain_models).permit( - :editor, :assistant, :copilot, :label_suggestion, - :audio_transcription, :help_center_search - ).to_h.stringify_keys + params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys end def permitted_captain_features - params.require(:captain_features).permit( - :editor, :assistant, :copilot, :label_suggestion, - :audio_transcription, :help_center_search - ).to_h.stringify_keys + params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys + end + + def captain_feature_keys + Llm::Models.feature_keys.map(&:to_sym) end def features_with_account_preferences preferences = Current.account.captain_preferences account_features = preferences[:features] || {} - account_models = preferences[:models] || {} Llm::Models.feature_keys.index_with do |feature_key| config = Llm::Models.feature_config(feature_key) + route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account) config.merge( + default: default_model_for(feature_key), enabled: account_features[feature_key] == true, - selected: account_models[feature_key] || config[:default] + model: route[:model], + selected: route[:model], + provider: route[:provider], + source: route[:source] ) end end + + def default_model_for(feature_key) + return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2') + + Llm::Models.default_model_for(feature_key) + end end diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb index f3b14d49f..1691b5489 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -6,6 +6,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: def create process_create + rescue CustomExceptions::Inbox::LimitExceeded => e + render_error_response(e) rescue StandardError => e render_could_not_create_error(e.message) end diff --git a/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb b/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb index f4ac05d6e..915ade8a3 100644 --- a/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/direct_uploads_controller.rb @@ -1,6 +1,17 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController + include DeviseTokenAuth::Concerns::SetUserByToken + include RequestExceptionHandler + include AccessTokenAuthHelper include EnsureCurrentAccountHelper + + skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token? + + around_action :handle_with_exception + before_action :authenticate_access_token!, if: :authenticate_by_access_token? + before_action :validate_bot_access_token!, if: :authenticate_by_access_token? + before_action :authenticate_user!, unless: :authenticate_by_access_token? before_action :current_account + before_action :validate_token_api_access, if: :authenticate_by_access_token? before_action :conversation def create @@ -11,6 +22,16 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage: private + def authenticate_by_access_token? + request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present? + end + + def validate_token_api_access + return if Current.account.api_and_webhooks_enabled? + + render json: { error: 'API access is not enabled for this account' }, status: :forbidden + end + def conversation @conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id]) end diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index 67381a715..b632ac78d 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -52,6 +52,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: end render json: { content: translated_content } + rescue Google::Cloud::Error => e + # `details` carries the clean human message; `message` includes gRPC debug noise + render_could_not_create_error(e.details.presence || e.message) end private diff --git a/app/controllers/api/v1/accounts/conversations/participants_controller.rb b/app/controllers/api/v1/accounts/conversations/participants_controller.rb index ebd02380f..7569142b2 100644 --- a/app/controllers/api/v1/accounts/conversations/participants_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/participants_controller.rb @@ -1,27 +1,40 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController + include Events::Types + def show @participants = @conversation.conversation_participants end def create + participant_ids_to_add = participants_to_be_added_ids + ActiveRecord::Base.transaction do - @participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } + @participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } end + notify_unread_count_change if participant_ids_to_add.any? end def update + participant_ids_to_add = participants_to_be_added_ids + participant_ids_to_remove = participants_to_be_removed_ids + changed_participant_ids = participant_ids_to_add + participant_ids_to_remove + ActiveRecord::Base.transaction do - participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } - participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } + participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) } + participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } end + notify_unread_count_change if changed_participant_ids.any? @participants = @conversation.conversation_participants render action: 'show' end def destroy + participant_ids_to_remove = current_participant_ids & params[:user_ids] + ActiveRecord::Base.transaction do params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy } end + notify_unread_count_change if participant_ids_to_remove.any? head :ok end @@ -38,4 +51,11 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun def current_participant_ids @current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id) end + + def notify_unread_count_change + return unless Current.account.feature_enabled?('conversation_unread_counts') + return unless Current.account.feature_enabled?('unread_count_for_filters') + + Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation) + end end diff --git a/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb index d9f15613b..0b2335475 100644 --- a/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb @@ -2,12 +2,28 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun before_action :ensure_unread_counts_enabled def index - counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform + counts = if filtered_unread_counts_enabled? + instrumentation.summarize_request(account_id: Current.account.id) { unread_counts } + else + unread_counts + end render json: { payload: counts } end private + def unread_counts + ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform + end + + def filtered_unread_counts_enabled? + Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG) + end + + def instrumentation + ::Conversations::UnreadCounts::FilteredCountInstrumentation + end + def ensure_unread_counts_enabled return if Current.account.feature_enabled?('conversation_unread_counts') diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 2e53fa7c9..38e8d94aa 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -164,6 +164,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro # rubocop:enable Rails/SkipsModelValidations ::Conversations::UnreadCounts::Notifier.new(@conversation).perform + ::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed! end def should_update_last_seen? diff --git a/app/controllers/api/v1/accounts/dashboard_apps_controller.rb b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb index a8d7ebcb9..4226db1cc 100644 --- a/app/controllers/api/v1/accounts/dashboard_apps_controller.rb +++ b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController + before_action :check_authorization before_action :fetch_dashboard_apps, except: [:create] before_action :fetch_dashboard_app, only: [:show, :update, :destroy] diff --git a/app/controllers/api/v1/accounts/data_imports_controller.rb b/app/controllers/api/v1/accounts/data_imports_controller.rb new file mode 100644 index 000000000..7f0d28e81 --- /dev/null +++ b/app/controllers/api/v1/accounts/data_imports_controller.rb @@ -0,0 +1,159 @@ +require 'csv' + +class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController + DATA_IMPORT_FEATURE = 'data_import'.freeze + + before_action :ensure_data_import_feature_enabled + before_action :set_data_import, only: [:show, :start, :abandon, :error_logs, :skip_logs] + before_action :check_authorization + + def index + @data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc) + data_import_ids = @data_imports.map(&:id) + @import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count + @skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count + end + + def show + render_show + end + + def validate_source + totals = validate_intercom_source + render json: { valid: true, totals: totals } + rescue DataImports::Intercom::Client::AuthenticationError + render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.') + rescue DataImports::Intercom::Client::Error + render_source_validation_error('Intercom could not be reached. Please try again.') + rescue ArgumentError => e + render_source_validation_error(e.message) + end + + def create + @data_import = creation_service.perform + unless @data_import + render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity + return + end + + DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) + render_show + rescue DataImports::Intercom::Client::AuthenticationError + render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.') + rescue DataImports::Intercom::Client::Error + render_source_validation_error('Intercom could not be reached. Please try again.') + rescue ArgumentError => e + render_source_validation_error(e.message) + end + + def start + restart_service = DataImports::Intercom::RestartService.new(account: Current.account, data_import: @data_import) + restart_result = restart_service.perform + @data_import = restart_service.data_import + if restart_result == :access_token_missing + render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity + return + end + + DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) if restart_result == :enqueue + render_show + end + + def abandon + @data_import.abandon! + render_show + end + + def skip_logs + send_data( + skip_logs_csv, + filename: "data-import-#{@data_import.id}-skip-logs.csv", + type: 'text/csv' + ) + end + + def error_logs + send_data( + error_logs_csv, + filename: "data-import-#{@data_import.id}-error-logs.csv", + type: 'text/csv' + ) + end + + private + + def ensure_data_import_feature_enabled + raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE) + end + + def set_data_import + @data_import = Current.account.data_imports.find(params[:id]) + end + + def check_authorization + authorize(@data_import || DataImport) + end + + def permitted_params + params.permit(:name, :source_provider, :access_token, import_types: []) + end + + def creation_service + DataImports::Intercom::CreationService.new( + account: Current.account, + initiated_by: Current.user, + source_params: permitted_params.to_h + ) + end + + def import_types + return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types) + + Array(permitted_params[:import_types]).compact_blank + end + + def validate_intercom_source + raise ArgumentError, 'Unsupported import source.' unless permitted_params[:source_provider] == 'intercom' + + DataImports::Intercom::CredentialsValidator.new( + access_token: permitted_params[:access_token], + import_types: import_types + ).perform + end + + def render_source_validation_error(message) + render json: { valid: false, message: message }, status: :unprocessable_entity + end + + def render_show + @import_errors_finder = DataImportErrorFinder.new(@data_import) + @skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params) + render :show + end + + def skip_logs_csv + logs_csv(@data_import.import_errors.skip_logs) + end + + def error_logs_csv + logs_csv(@data_import.import_errors.non_skip_logs) + end + + def logs_csv(logs) + CSV.generate(headers: true) do |csv| + csv << %w[created_at kind source_object_type source_object_id error_code message details] + + logs.order(:created_at).find_each do |log| + csv << [ + log.created_at.iso8601, + log.details['kind'], + log.source_object_type, + log.source_object_id, + log.error_code, + log.message, + log.details.to_json + ] + end + end + end +end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 757af9b62..822fce7db 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -2,7 +2,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController include Api::V1::InboxesHelper before_action :fetch_inbox, except: [:index, :create] before_action :fetch_agent_bot, only: [:set_agent_bot] - before_action :validate_limit, only: [:create] # we are already handling the authorization in fetch inbox before_action :check_authorization, except: [:show] @@ -46,11 +45,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def update - inbox_params = permitted_params.except(:channel, :csat_config) - inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present? - @inbox.update!(inbox_params) - update_inbox_working_hours - update_channel if channel_update_required? + continue_update = false + + ActiveRecord::Base.transaction do + continue_update = update_branded_email_layout + raise ActiveRecord::Rollback unless continue_update + + inbox_params = permitted_params.except(:channel, :csat_config) + inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present? + @inbox.update!(inbox_params) + update_inbox_working_hours + update_channel if channel_update_required? + end + + return unless continue_update end def agent_bot @@ -125,8 +133,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def reauthorize_and_update_channel(channel_attributes) - @inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!) @inbox.channel.update!(permitted_params(channel_attributes)[:channel]) + @inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!) end def update_channel_feature_flags @@ -156,6 +164,34 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController formatted['template'] = config['template'] if config['template'].present? end + def update_branded_email_layout + return true unless params.key?(:branded_email_layout) + + branded_email_layout = normalized_branded_email_layout + + unless Current.account.feature_enabled?(:branded_email_templates) + return true if branded_email_layout.blank? + + render_could_not_create_error('Branded email templates feature is not enabled') + return false + end + + unless @inbox.email? + return true if branded_email_layout.blank? + + render_could_not_create_error('Branded email layout is only supported for email inboxes') + return false + end + + @inbox.update_branded_email_layout!(branded_email_layout) + true + rescue ActiveRecord::RecordInvalid => e + render_could_not_create_error(e.record.errors.full_messages.join(', ')) + false + end + + def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout] + def inbox_attributes [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, diff --git a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb index 845caab5e..7bda1c802 100644 --- a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC end render_response( - dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user) + dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message) ) end diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb index c65a3031d..07decfde0 100644 --- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -6,7 +6,10 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts { redirect_uri: "#{base_url}/microsoft/callback", scope: scope, - state: state + state: state, + # Force the Microsoft account picker so an already-signed-in account does not + # silently authorize and re-bind to an existing inbox in the new-inbox flow. + prompt: 'select_account' } ) if redirect_url diff --git a/app/controllers/api/v1/accounts/onboardings_controller.rb b/app/controllers/api/v1/accounts/onboardings_controller.rb index 181e4965e..d7c49b35d 100644 --- a/app/controllers/api/v1/accounts/onboardings_controller.rb +++ b/app/controllers/api/v1/accounts/onboardings_controller.rb @@ -1,17 +1,19 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController before_action :check_admin_authorization? + ONBOARDING_STEP_KEY = 'onboarding_step'.freeze + STEP_ACCOUNT_DETAILS = 'account_details'.freeze + STEP_INBOX_SETUP = 'inbox_setup'.freeze + ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze + def update + return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step]) + @account = Current.account - finalize = finalizing_account_details? - - @account.assign_attributes(account_params) - @account.custom_attributes.merge!(custom_attributes_params) - @account.custom_attributes.delete('onboarding_step') if finalize - @account.save! - - # TODO: re-enable when the help center generation UI is ready to surface progress - # Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present? + # The client declares the step it is completing; `account_details` runs + # `complete_account_details`, and so on. The known-step guard above keeps the + # client value from `send`-ing an arbitrary method. + send("complete_#{params[:onboarding_step]}") render 'api/v1/accounts/update', format: :json end @@ -22,12 +24,48 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll private - def finalizing_account_details? - @account.custom_attributes['onboarding_step'] == 'account_details' + def complete_account_details + # Only act while the cursor still points here, so a stale replay after + # onboarding finished can't re-enter it. + return unless current_step == STEP_ACCOUNT_DETAILS + + @account.assign_attributes(account_params) + @account.custom_attributes.merge!(custom_attributes_params) + + # inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded + # environment check); self-hosted finishes onboarding here. + if ChatwootApp.chatwoot_cloud? + move_to_step(STEP_INBOX_SETUP) + create_onboarding_inboxes + else + finish_onboarding + end end - def website - custom_attributes_params[:website] + def complete_inbox_setup + # Only finalize while the cursor still points here, so a stale or out-of-order + # request can't end onboarding early. Replays are no-ops. + return unless current_step == STEP_INBOX_SETUP + + finish_onboarding + end + + def current_step + @account.custom_attributes[ONBOARDING_STEP_KEY] + end + + def move_to_step(step) + @account.custom_attributes[ONBOARDING_STEP_KEY] = step + @account.save! + end + + def finish_onboarding + @account.custom_attributes.delete(ONBOARDING_STEP_KEY) + @account.save! + end + + def create_onboarding_inboxes + Onboarding::WebWidgetCreationService.new(@account, Current.user).perform end def account_params diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index c74c0ecfc..bdaf82f6c 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -81,7 +81,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController :name, :page_title, :slug, :archived, { config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] }, { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }, - { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] } + { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }, + { popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] } ) end @@ -89,6 +90,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController params.dig(:portal, :config, :locale_translations)&.keys || [] end + def popular_content_keys + params.dig(:portal, :config, :popular_content)&.keys || [] + end + def live_chat_widget_params permitted_params = params.permit(:inbox_id) return {} unless permitted_params.key?(:inbox_id) diff --git a/app/controllers/api/v1/accounts/teams_controller.rb b/app/controllers/api/v1/accounts/teams_controller.rb index e8688dcfb..6239e00eb 100644 --- a/app/controllers/api/v1/accounts/teams_controller.rb +++ b/app/controllers/api/v1/accounts/teams_controller.rb @@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController end def team_params - params.require(:team).permit(:name, :description, :allow_auto_assign) + params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color) end end diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index d52f396fc..580ae77c6 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -1,4 +1,6 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController + # Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins. + before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? } before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } # POST /api/v1/accounts/:account_id/whatsapp/authorization @@ -8,8 +10,10 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: validate_embedded_signup_params! channel = process_embedded_signup render_success_response(channel.inbox) - rescue StandardError => e + rescue CustomExceptions::Inbox::LimitExceeded => e render_error_response(e) + rescue StandardError => e + render_embedded_signup_error(e) end private @@ -29,7 +33,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: end def validate_reauthorization_required - return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup? + return if @inbox.channel.reauthorization_required? || can_reconfigure_channel? render json: { success: false, @@ -37,10 +41,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: }, status: :unprocessable_entity end - def can_upgrade_to_embedded_signup? + def can_reconfigure_channel? channel = @inbox.channel return false unless channel.provider == 'whatsapp_cloud' + # Reconfiguring a live embedded-signup channel requires the feature flag. + return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup' + true end @@ -55,7 +62,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: render json: response end - def render_error_response(error) + def render_embedded_signup_error(error) Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}" Rails.logger.error error.backtrace.join("\n") render json: { diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index fb991949a..9438a1660 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -8,6 +8,7 @@ class Api::V1::AccountsController < Api::BaseController before_action :ensure_account_name, only: [:create] before_action :validate_captcha, only: [:create] before_action :fetch_account, except: [:create] + before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create] before_action :check_authorization, except: [:create] rescue_from CustomExceptions::Account::InvalidEmail, @@ -105,6 +106,12 @@ class Api::V1::AccountsController < Api::BaseController @current_account_user = @account.account_users.find_by(user_id: current_user.id) end + def validate_token_api_access + return if @account.api_and_webhooks_enabled? + + render json: { error: 'API access is not enabled for this account' }, status: :forbidden + end + def account_params params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name) end diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index 5b87e2d1a..3912e5b6e 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -59,6 +59,10 @@ class Api::V1::Widget::BaseController < ApplicationController permitted_params.dig(:contact, :phone_number) end + def contact_custom_attributes + permitted_params.dig(:contact, :custom_attributes)&.to_h + end + def browser_params { browser_name: browser.name, diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index 6c595ab59..9a7d5193a 100644 --- a/app/controllers/api/v1/widget/contacts_controller.rb +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController include WidgetHelper before_action :validate_hmac, only: [:set_user] + before_action :validate_hmac_for_identified_update, only: [:update] def show; end @@ -46,6 +47,16 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController @contact.identifier.present? && @contact.identifier != permitted_params[:identifier] end + # The plain update endpoint is also used for anonymous prechat updates + # (name/email/phone/custom_attributes with no identifier), which must keep + # working on hmac_mandatory inboxes. Only the identity-binding path, where an + # identifier is supplied and the contact can be rebound, requires HMAC. + def validate_hmac_for_identified_update + return if params[:identifier].blank? + + validate_hmac + end + def validate_hmac return unless should_verify_hmac? @@ -62,11 +73,15 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController end def valid_hmac? - params[:identifier_hash] == OpenSSL::HMAC.hexdigest( + expected_hash = OpenSSL::HMAC.hexdigest( 'sha256', @web_widget.hmac_token, params[:identifier].to_s ) + identifier_hash = params[:identifier_hash].to_s + return false unless identifier_hash.bytesize == expected_hash.bytesize + + ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash) end def permitted_params diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index 00e718614..8f5977d54 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -19,7 +19,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController def process_update_contact @contact = ContactIdentifyAction.new( contact: @contact, - params: { email: contact_email, phone_number: contact_phone_number, name: contact_name }, + params: { email: contact_email, phone_number: contact_phone_number, name: contact_name, custom_attributes: contact_custom_attributes }, retain_original_contact_name: true, discard_invalid_attrs: true ).perform @@ -95,7 +95,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def permitted_params - params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number], + params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }], message: [:content, :referer_url, :timestamp, :echo_id], custom_attributes: {}) end diff --git a/app/controllers/api/v1/widget/integrations/dyte_controller.rb b/app/controllers/api/v1/widget/integrations/dyte_controller.rb index 0661b4a3c..fde425b26 100644 --- a/app/controllers/api/v1/widget/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/widget/integrations/dyte_controller.rb @@ -10,7 +10,8 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr response = dyte_processor_service.add_participant_to_meeting( @message.content_attributes['data']['meeting_id'], - @conversation.contact + @conversation.contact, + @message ) render_response(response) end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 192b3619c..93be19eb9 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -51,6 +51,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic') end + def drilldown + return head :unauthorized unless Current.account_user.administrator? + return head :unprocessable_entity unless valid_drilldown_params? + + render json: V2::Reports::DrilldownBuilder.new(Current.account, drilldown_params).build + end + def conversations return head :unprocessable_entity if params[:type].blank? @@ -133,6 +140,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController }) end + def drilldown_params + permitted_params = params.permit( + :metric, :id, :since, :until, :group_by, :timezone_offset, :bucket_timestamp, :page, :per_page + ).to_h.symbolize_keys + permitted_params.merge( + type: (params[:type].presence || 'account').to_sym, + business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + ) + end + + def valid_drilldown_params? + %i[metric bucket_timestamp since until].all? { |param| params[param].present? } && + Reports::ReportMetricRegistry.supported?(params[:metric]) && + V2::Reports::DrilldownBuilder.supported_dimension_type?(params[:type]) && Reports::DrilldownTimestampValidator.valid?(params) + end + def conversation_params { type: params[:type].to_sym, diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb index ea36a48f2..7ed39fa98 100644 --- a/app/controllers/concerns/ensure_current_account_helper.rb +++ b/app/controllers/concerns/ensure_current_account_helper.rb @@ -14,6 +14,8 @@ module EnsureCurrentAccountHelper account_accessible_for_user?(account) elsif @resource.is_a?(AgentBot) account_accessible_for_bot?(account) + else + render_unauthorized(I18n.t('errors.account.not_authorized')) end account end @@ -21,7 +23,7 @@ module EnsureCurrentAccountHelper def account_accessible_for_user?(account) @current_account_user = account.account_users.find_by(user_id: current_user.id) Current.account_user = @current_account_user - render_unauthorized('You are not authorized to access this account') unless @current_account_user + render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user end def account_accessible_for_bot?(account) diff --git a/app/controllers/concerns/portal_home_data.rb b/app/controllers/concerns/portal_home_data.rb new file mode 100644 index 000000000..71eaad081 --- /dev/null +++ b/app/controllers/concerns/portal_home_data.rb @@ -0,0 +1,65 @@ +module PortalHomeData + extend ActiveSupport::Concern + + private + + def load_home_data + load_recommended_content + # The classic hero only needs the recommendations above; the rest is + # documentation-layout home data (also used on custom-domain home pages). + return unless @portal.layout == 'documentation' + + @visible_categories = @portal.categories + .where(locale: @locale) + .joins(:articles).where(articles: { status: :published }) + .order(position: :asc) + .group('categories.id') + @popular_topics = @recommended_categories.presence || @visible_categories.first(3) + @featured = base_articles.order_by_views.limit(6) + @category_contributors = build_category_contributors(@visible_categories) + end + + def load_recommended_content + @recommended_categories = recommended_categories + @recommended_articles = recommended_articles + end + + def base_articles + @base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category) + end + + # Admin-recommended categories for the locale, in the chosen order. Unlike the + # position-based fallback, published articles aren't required: the admin's pick wins. + def recommended_categories + ids = @portal.popular_category_ids(@locale) + ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids) + end + + # Admin-recommended articles for the locale, in the chosen order, limited to + # published articles that still exist. + def recommended_articles + ids = @portal.popular_article_ids(@locale) + ordered_by_ids(base_articles.where(id: ids), ids) + end + + # Loads the scope and returns its records ordered to match `ids`, dropping any + # that no longer exist. Skips the query entirely when `ids` is blank. + def ordered_by_ids(scope, ids) + return [] if ids.blank? + + by_id = scope.index_by(&:id) + ids.filter_map { |id| by_id[id] } + end + + def build_category_contributors(categories) + category_ids = categories.map(&:id) + return {} if category_ids.empty? + + @portal.articles + .published + .where(locale: @locale, category_id: category_ids) + .includes(:author) + .group_by(&:category_id) + .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } + end +end diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb index ccab0090a..43d6edf1f 100644 --- a/app/controllers/concerns/request_exception_handler.rb +++ b/app/controllers/concerns/request_exception_handler.rb @@ -1,8 +1,15 @@ module RequestExceptionHandler extend ActiveSupport::Concern + QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [ + 'ActiveRecord::QueryCanceled', + 'PG::QueryCanceled', + 'canceling statement due to statement timeout' + ].freeze + included do rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid + rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response end private @@ -18,6 +25,9 @@ module RequestExceptionHandler rescue ActionController::ParameterMissing => e log_handled_error(e) render_could_not_create_error(e.message) + rescue ActiveRecord::QueryCanceled => e + log_handled_error(e) + render_could_not_create_error(database_query_canceled_message) ensure # to address the thread variable leak issues in Puma/Thin webserver Current.reset @@ -31,8 +41,8 @@ module RequestExceptionHandler render json: { error: message }, status: :not_found end - def render_could_not_create_error(message) - render json: { error: message }, status: :unprocessable_entity + def render_could_not_create_error(error) + render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity end def render_payment_required(message) @@ -59,4 +69,19 @@ module RequestExceptionHandler def log_handled_error(exception) logger.info("Handled error: #{exception.inspect}") end + + def sanitized_error_message(message) + return database_query_canceled_message if database_query_canceled_message?(message) + + message + end + + def database_query_canceled_message?(message) + error_message = message.to_s + QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) } + end + + def database_query_canceled_message + I18n.t('errors.database.query_canceled') + end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index b6df015f7..a72687b42 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,5 +1,6 @@ class DashboardController < ActionController::Base include SwitchLocale + include PortalHomeData GLOBAL_CONFIG_KEYS = %w[ LOGO @@ -63,6 +64,8 @@ class DashboardController < ActionController::Base return unless @portal @locale = @portal.default_locale + request.variant = :documentation if @portal.layout == 'documentation' + load_home_data render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return end diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb index cd317363c..e9065119f 100644 --- a/app/controllers/instagram/callbacks_controller.rb +++ b/app/controllers/instagram/callbacks_controller.rb @@ -11,6 +11,8 @@ class Instagram::CallbacksController < ApplicationController end process_successful_authorization + rescue CustomExceptions::Inbox::LimitExceeded => e + handle_limit_error(e) rescue StandardError => e handle_error(e) end @@ -47,6 +49,14 @@ class Instagram::CallbacksController < ApplicationController redirect_to_error_page(error_info) end + def handle_limit_error(error) + redirect_to_error_page( + 'error_type' => error.class.name, + 'code' => Rack::Utils.status_code(error.http_status), + 'error_message' => error.message + ) + end + # Extract error details from the exception def extract_error_info(error) if error.is_a?(OAuth2::Error) diff --git a/app/controllers/public/api/v1/inboxes/contacts_controller.rb b/app/controllers/public/api/v1/inboxes/contacts_controller.rb index 835c2596b..764e02a72 100644 --- a/app/controllers/public/api/v1/inboxes/contacts_controller.rb +++ b/app/controllers/public/api/v1/inboxes/contacts_controller.rb @@ -18,7 +18,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon contact: @contact_inbox.contact, params: permitted_params.to_h.deep_symbolize_keys.except(:identifier) ) - render json: contact_identify_action.perform + contact_identify_action.perform + @contact_inbox.reload end private @@ -35,11 +36,15 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon end def valid_hmac? - params[:identifier_hash] == OpenSSL::HMAC.hexdigest( + expected_hash = OpenSSL::HMAC.hexdigest( 'sha256', @inbox_channel.hmac_token, params[:identifier].to_s ) + identifier_hash = params[:identifier_hash].to_s + return false unless identifier_hash.bytesize == expected_hash.bytesize + + ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash) end def permitted_params diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 2991b84d2..7b592fbd8 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -24,7 +24,11 @@ class Public::Api::V1::Portals::BaseController < PublicController end def set_view_variant - request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled + request.variant = if @is_plain_layout_enabled + :plain + elsif @portal_layout == 'documentation' + :documentation + end end def portal @@ -39,9 +43,11 @@ class Public::Api::V1::Portals::BaseController < PublicController end def switch_locale_with_portal(&) - @locale = validate_and_get_locale(params[:locale]) + # Keep @locale as the portal's own locale code (e.g. th_TH) for content queries, + # while UI translations fall back to an available I18n locale (e.g. th). + @locale = params[:locale] - I18n.with_locale(@locale, &) + I18n.with_locale(validate_and_get_locale(@locale), &) end def switch_locale_with_article(&) @@ -49,13 +55,12 @@ class Public::Api::V1::Portals::BaseController < PublicController Rails.logger.info "Article: not found for slug: #{params[:article_slug]}" render_404 && return if article.blank? - article_locale = if article.category.present? - article.category.locale - else - article.locale - end - @locale = validate_and_get_locale(article_locale) - I18n.with_locale(@locale, &) + @locale = if article.category.present? + article.category.locale + else + article.locale + end + I18n.with_locale(validate_and_get_locale(@locale), &) end def allow_iframe_requests @@ -64,6 +69,8 @@ class Public::Api::V1::Portals::BaseController < PublicController def render_404 portal + # set_locale can render_404 before the child's set_view_variant runs; set it here so plain 404s stay chrome-less + set_view_variant render 'public/api/v1/portals/error/404', status: :not_found end diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index 57db11aec..da7d9e6a5 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -1,11 +1,13 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController + include PortalHomeData + before_action :ensure_custom_domain_request, only: [:show] before_action :redirect_to_portal_with_locale, only: [:show] before_action :portal before_action :set_portal_layout before_action :set_view_variant before_action :ensure_portal_feature_enabled - before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' } + before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled } layout 'portal' def show @@ -31,28 +33,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl portal redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" end - - def load_home_data - base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) - @visible_categories = @portal.categories - .where(locale: @locale) - .joins(:articles).where(articles: { status: :published }) - .order(position: :asc) - .group('categories.id') - @popular_topics = @visible_categories.first(3) - @featured = base_articles.order_by_views.limit(6) - @category_contributors = build_category_contributors(@visible_categories) - end - - def build_category_contributors(categories) - category_ids = categories.map(&:id) - return {} if category_ids.empty? - - @portal.articles - .published - .where(locale: @locale, category_id: category_ids) - .includes(:author) - .group_by(&:category_id) - .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } - end end diff --git a/app/controllers/super_admin/accounts_controller.rb b/app/controllers/super_admin/accounts_controller.rb index 27ce587f7..59b99c37e 100644 --- a/app/controllers/super_admin/accounts_controller.rb +++ b/app/controllers/super_admin/accounts_controller.rb @@ -35,7 +35,8 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController # def resource_params permitted_params = super - permitted_params[:limits] = permitted_params[:limits].to_h.compact + permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits) + permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models) permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present? permitted_params end diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb index 20c0ee9c0..a39fec5ed 100644 --- a/app/controllers/tiktok/callbacks_controller.rb +++ b/app/controllers/tiktok/callbacks_controller.rb @@ -6,6 +6,8 @@ class Tiktok::CallbacksController < ApplicationController return handle_ungranted_scopes_error unless all_scopes_granted? process_successful_authorization + rescue CustomExceptions::Inbox::LimitExceeded => e + handle_limit_error(e) rescue StandardError => e handle_error(e) end @@ -36,6 +38,14 @@ class Tiktok::CallbacksController < ApplicationController redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message) end + def handle_limit_error(error) + redirect_to_error_page( + error_type: error.class.name, + code: Rack::Utils.status_code(error.http_status), + error_message: error.message + ) + end + # Handles the case when a user denies permissions or cancels the authorization flow def handle_authorization_error redirect_to_error_page( diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index 9be674f11..b2683f2e0 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -18,6 +18,7 @@ class AccountDashboard < Administrate::BaseDashboard # Add all_features last so it appears after manually_managed_features attributes[:all_features] = AccountFeaturesField + attributes[:captain_models] = CaptainModelOverridesField attributes else @@ -57,6 +58,7 @@ class AccountDashboard < Administrate::BaseDashboard attrs = %i[custom_attributes limits] attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? attrs << :all_features + attrs << :captain_models attrs else [] @@ -79,6 +81,7 @@ class AccountDashboard < Administrate::BaseDashboard attrs = %i[limits] attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? attrs << :all_features + attrs << :captain_models attrs else [] @@ -117,7 +120,7 @@ class AccountDashboard < Administrate::BaseDashboard # to prevent an error from being raised (wrong number of arguments) # Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204 def permitted_attributes(action) - attrs = super + [limits: {}] + attrs = super + [limits: {}, captain_models: {}] # Add manually_managed_features to permitted attributes only for Chatwoot Cloud attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud? diff --git a/app/drops/conversation_drop.rb b/app/drops/conversation_drop.rb index d62642885..f25435ff0 100644 --- a/app/drops/conversation_drop.rb +++ b/app/drops/conversation_drop.rb @@ -1,6 +1,10 @@ class ConversationDrop < BaseDrop include MessageFormatHelper + def id + @obj.try(:display_id) + end + def display_id @obj.try(:display_id) end diff --git a/app/drops/inbox_drop.rb b/app/drops/inbox_drop.rb index 563916d5f..29698fb4b 100644 --- a/app/drops/inbox_drop.rb +++ b/app/drops/inbox_drop.rb @@ -2,4 +2,18 @@ class InboxDrop < BaseDrop def name @obj.try(:name) end + + def business_name + @obj.try(:sanitized_business_name) + end + + def avatar_url + @obj.try(:avatar_url) + end + + def email + return unless @obj.try(:email?) + + @obj.try(:email_address).presence || @obj.try(:channel).try(:email) + end end diff --git a/app/finders/data_import_error_finder.rb b/app/finders/data_import_error_finder.rb new file mode 100644 index 000000000..69d85b23c --- /dev/null +++ b/app/finders/data_import_error_finder.rb @@ -0,0 +1,11 @@ +class DataImportErrorFinder + RESULTS_LIMIT = 5 + + def initialize(data_import) + @data_import = data_import + end + + def import_errors + @data_import.import_errors.non_skip_logs.order(created_at: :desc).limit(RESULTS_LIMIT) + end +end diff --git a/app/finders/data_import_skip_log_finder.rb b/app/finders/data_import_skip_log_finder.rb new file mode 100644 index 000000000..ee932f1da --- /dev/null +++ b/app/finders/data_import_skip_log_finder.rb @@ -0,0 +1,35 @@ +class DataImportSkipLogFinder + RESULTS_LIMIT = 5 + SOURCE_OBJECT_TYPES = %w[contact conversation message].freeze + + attr_reader :selected_source_object_type + + def initialize(data_import, params = {}) + @data_import = data_import + @selected_source_object_type = valid_source_object_type(params[:skip_logs_type]) + end + + def skip_logs + filtered_scope.order(created_at: :desc).limit(RESULTS_LIMIT) + end + + def counts_by_type + base_scope.group(:source_object_type).count + end + + private + + def base_scope + @base_scope ||= @data_import.import_errors.skip_logs + end + + def filtered_scope + return base_scope if selected_source_object_type.blank? + + base_scope.where(source_object_type: selected_source_object_type) + end + + def valid_source_object_type(source_object_type) + source_object_type if SOURCE_OBJECT_TYPES.include?(source_object_type) + end +end diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 8a10fa99c..6c64dd009 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -114,10 +114,4 @@ module Api::V1::InboxesHelper 'sms' => Current.account.sms_channels }[permitted_params[:channel][:type]] end - - def validate_limit - return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes] - - render_payment_required('Account limit exceeded. Upgrade to a higher plan') - end end diff --git a/app/helpers/billing_helper.rb b/app/helpers/billing_helper.rb index e2ada7e86..7545b6d8f 100644 --- a/app/helpers/billing_helper.rb +++ b/app/helpers/billing_helper.rb @@ -22,4 +22,16 @@ module BillingHelper def agents(account) account.users.count end + + # current_period_end moved to the subscription item in newer Stripe API versions; read both. + def subscription_period_end(subscription) + subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end') + end + + def subscription_ends_on(subscription) + period_end = subscription_period_end(subscription) + return if period_end.blank? + + Time.zone.at(period_end) + end end diff --git a/app/helpers/filters/filter_helper.rb b/app/helpers/filters/filter_helper.rb index 4f345676e..d32c9468a 100644 --- a/app/helpers/filters/filter_helper.rb +++ b/app/helpers/filters/filter_helper.rb @@ -68,6 +68,8 @@ module Filters::FilterHelper when 'text_case_insensitive' text_case_insensitive_filter(query_hash, filter_operator_value) else + return text_cast_filter(query_hash, filter_operator_value) if text_search_on_display_id?(query_hash) + default_filter(query_hash, filter_operator_value) end end @@ -82,10 +84,18 @@ module Filters::FilterHelper "#{filter_operator_value} #{query_hash[:query_operator]}" end + def text_cast_filter(query_hash, filter_operator_value) + "(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::text #{filter_operator_value} #{query_hash[:query_operator]}" + end + def default_filter(query_hash, filter_operator_value) "#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}" end + def text_search_on_display_id?(query_hash) + query_hash[:attribute_key] == 'display_id' && %w[contains does_not_contain].include?(query_hash[:filter_operator]) + end + def validate_single_condition(condition) return if condition['query_operator'].nil? return if condition['query_operator'].empty? diff --git a/app/javascript/dashboard/api/assignableAgents.js b/app/javascript/dashboard/api/assignableAgents.js index 5b999facf..febb05ff9 100644 --- a/app/javascript/dashboard/api/assignableAgents.js +++ b/app/javascript/dashboard/api/assignableAgents.js @@ -6,9 +6,12 @@ class AssignableAgents extends ApiClient { super('assignable_agents', { accountScoped: true }); } - get(inboxIds) { + get(inboxIds, { includeAgentBots = false } = {}) { return axios.get(this.url, { - params: { inbox_ids: inboxIds }, + params: { + inbox_ids: inboxIds, + ...(includeAgentBots ? { include_agent_bots: true } : {}), + }, }); } } diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js index 157eba74e..1fc17798d 100644 --- a/app/javascript/dashboard/api/captain/assistant.js +++ b/app/javascript/dashboard/api/captain/assistant.js @@ -1,6 +1,10 @@ /* global axios */ import ApiClient from '../ApiClient'; +// Viewer's UTC offset in hours, matching the reports API convention so the +// backend can anchor calendar ranges to the viewer's day. +const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60; + class CaptainAssistant extends ApiClient { constructor() { super('captain/assistants', { accountScoped: true }); @@ -21,6 +25,32 @@ class CaptainAssistant extends ApiClient { message_history: messageHistory, }); } + + getStats({ assistantId, range }) { + return axios.get(`${this.url}/${assistantId}/stats`, { + params: { range, timezone_offset: getTimezoneOffset() }, + }); + } + + getSummary({ assistantId, range }) { + return axios.get(`${this.url}/${assistantId}/summary`, { + params: { range, timezone_offset: getTimezoneOffset() }, + }); + } + + getDrilldown({ assistantId, metric, range, page, signal }) { + const requestConfig = { + params: { + metric, + range, + timezone_offset: getTimezoneOffset(), + page, + }, + }; + if (signal) requestConfig.signal = signal; + + return axios.get(`${this.url}/${assistantId}/drilldown`, requestConfig); + } } export default new CaptainAssistant(); diff --git a/app/javascript/dashboard/api/captain/messageReports.js b/app/javascript/dashboard/api/captain/messageReports.js new file mode 100644 index 000000000..2df1e5747 --- /dev/null +++ b/app/javascript/dashboard/api/captain/messageReports.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class MessageReports extends ApiClient { + constructor() { + super('captain/message_reports', { accountScoped: true }); + } +} + +export default new MessageReports(); diff --git a/app/javascript/dashboard/api/dataImports.js b/app/javascript/dashboard/api/dataImports.js new file mode 100644 index 000000000..b4c15b98a --- /dev/null +++ b/app/javascript/dashboard/api/dataImports.js @@ -0,0 +1,39 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class DataImportsAPI extends ApiClient { + constructor() { + super('data_imports', { accountScoped: true }); + } + + start(id) { + return axios.post(`${this.url}/${id}/start`); + } + + abandon(id) { + return axios.post(`${this.url}/${id}/abandon`); + } + + show(id, params = {}) { + return axios.get(`${this.url}/${id}`, { params }); + } + + validateSource(payload) { + return axios.post(`${this.url}/validate_source`, payload); + } + + downloadSkipLogs(id) { + return axios.get(`${this.url}/${id}/skip_logs.csv`, { + responseType: 'blob', + }); + } + + downloadErrorLogs(id) { + return axios.get(`${this.url}/${id}/error_logs.csv`, { + responseType: 'blob', + }); + } +} + +export default new DataImportsAPI(); diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index 9e6d40a62..03456a288 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -14,6 +14,10 @@ class EnterpriseAccountAPI extends ApiClient { return axios.post(`${this.url}subscription`); } + selectBillingCurrency(currency) { + return axios.post(`${this.url}select_billing_currency`, { currency }); + } + getLimits() { return axios.get(`${this.url}limits`); } @@ -27,6 +31,11 @@ class EnterpriseAccountAPI extends ApiClient { createTopupCheckout(credits) { return axios.post(`${this.url}topup_checkout`, { credits }); } + + // Topup packages for the account's billing currency. + getTopupOptions() { + return axios.get(`${this.url}topup_options`); + } } export default new EnterpriseAccountAPI(); diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js index bab45bcb5..610ba1e57 100644 --- a/app/javascript/dashboard/api/helpCenter/articles.js +++ b/app/javascript/dashboard/api/helpCenter/articles.js @@ -16,6 +16,8 @@ class ArticlesAPI extends PortalsAPI { authorId, categorySlug, sort, + query, + signal, }) { const url = getArticleSearchURL({ pageNumber, @@ -25,10 +27,11 @@ class ArticlesAPI extends PortalsAPI { authorId, categorySlug, sort, + query, host: this.url, }); - return axios.get(url); + return axios.get(url, { signal }); } searchArticles({ portalSlug, query }) { diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index f94fca452..08820aac9 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -62,9 +62,10 @@ class ConversationApi extends ApiClient { }); } - assignAgent({ conversationId, agentId }) { + assignAgent({ conversationId, agentId, assigneeType }) { return axios.post(`${this.url}/${conversationId}/assignments`, { assignee_id: agentId, + assignee_type: assigneeType, }); } diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 00f040f8e..daa0cb11d 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -31,6 +31,42 @@ class ReportsAPI extends ApiClient { }); } + getDrilldown({ + metric, + bucketTimestamp, + from, + to, + type = 'account', + id, + groupBy, + businessHours, + page, + perPage, + signal, + }) { + const requestConfig = { + params: { + metric, + bucket_timestamp: bucketTimestamp, + since: from, + until: to, + type, + id, + group_by: groupBy, + business_hours: businessHours, + timezone_offset: getTimeOffset(), + page, + per_page: perPage, + }, + }; + + if (signal) { + requestConfig.signal = signal; + } + + return axios.get(`${this.url}/drilldown`, requestConfig); + } + // eslint-disable-next-line default-param-last getSummary(since, until, type = 'account', id, groupBy, businessHours) { return axios.get(`${this.url}/summary`, { diff --git a/app/javascript/dashboard/api/specs/article.spec.js b/app/javascript/dashboard/api/specs/article.spec.js index b40613739..9f7052ed5 100644 --- a/app/javascript/dashboard/api/specs/article.spec.js +++ b/app/javascript/dashboard/api/specs/article.spec.js @@ -37,7 +37,8 @@ describe('#PortalAPI', () => { authorId: '1', }); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1' + '/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1', + { signal: undefined } ); }); }); diff --git a/app/javascript/dashboard/api/specs/assignableAgents.spec.js b/app/javascript/dashboard/api/specs/assignableAgents.spec.js index d553d55cb..be00cf07f 100644 --- a/app/javascript/dashboard/api/specs/assignableAgents.spec.js +++ b/app/javascript/dashboard/api/specs/assignableAgents.spec.js @@ -26,5 +26,15 @@ describe('#AssignableAgentsAPI', () => { }, }); }); + + it('#getAssignableAgents with agent bots', () => { + assignableAgentsAPI.get([1], { includeAgentBots: true }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', { + params: { + inbox_ids: [1], + include_agent_bots: true, + }, + }); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js index de0d7a7d0..ea0ef3e75 100644 --- a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js @@ -90,11 +90,16 @@ describe('#ConversationAPI', () => { }); it('#assignAgent', () => { - conversationAPI.assignAgent({ conversationId: 12, agentId: 34 }); + conversationAPI.assignAgent({ + conversationId: 12, + agentId: 34, + assigneeType: 'AgentBot', + }); expect(axiosMock.post).toHaveBeenCalledWith( `/api/v1/conversations/12/assignments`, { assignee_id: 34, + assignee_type: 'AgentBot', } ); }); diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index e458633d0..178c98a70 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -1,6 +1,8 @@ import reportsAPI from '../reports'; import ApiClient from '../ApiClient'; +const timezoneOffset = () => -new Date().getTimezoneOffset() / 60; + describe('#Reports API', () => { it('creates correct instance', () => { expect(reportsAPI).toBeInstanceOf(ApiClient); @@ -11,6 +13,7 @@ describe('#Reports API', () => { expect(reportsAPI).toHaveProperty('update'); expect(reportsAPI).toHaveProperty('delete'); expect(reportsAPI).toHaveProperty('getReports'); + expect(reportsAPI).toHaveProperty('getDrilldown'); expect(reportsAPI).toHaveProperty('getSummary'); expect(reportsAPI).toHaveProperty('getAgentReports'); expect(reportsAPI).toHaveProperty('getLabelReports'); @@ -42,11 +45,14 @@ describe('#Reports API', () => { }); expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', { params: { + business_hours: undefined, + group_by: undefined, + id: undefined, metric: 'conversations_count', since: 1621103400, until: 1621621800, type: 'account', - timezone_offset: -0, + timezone_offset: timezoneOffset(), }, }); }); @@ -59,13 +65,70 @@ describe('#Reports API', () => { group_by: undefined, id: undefined, since: 1621103400, - timezone_offset: -0, + timezone_offset: timezoneOffset(), type: 'account', until: 1621621800, }, }); }); + it('#getDrilldown', () => { + reportsAPI.getDrilldown({ + metric: 'incoming_messages_count', + bucketTimestamp: 1621103400, + from: 1621103400, + to: 1621621800, + type: 'inbox', + id: 1, + groupBy: 'day', + businessHours: false, + page: 2, + perPage: 25, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', { + params: { + metric: 'incoming_messages_count', + bucket_timestamp: 1621103400, + since: 1621103400, + until: 1621621800, + type: 'inbox', + id: 1, + group_by: 'day', + business_hours: false, + timezone_offset: timezoneOffset(), + page: 2, + per_page: 25, + }, + }); + }); + + it('#getDrilldown with abort signal', () => { + const controller = new AbortController(); + + reportsAPI.getDrilldown({ + metric: 'incoming_messages_count', + bucketTimestamp: 1621103400, + signal: controller.signal, + }); + + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', { + params: { + metric: 'incoming_messages_count', + bucket_timestamp: 1621103400, + since: undefined, + until: undefined, + type: 'account', + id: undefined, + group_by: undefined, + business_hours: undefined, + timezone_offset: timezoneOffset(), + page: undefined, + per_page: undefined, + }, + signal: controller.signal, + }); + }); + it('#getAgentReports', () => { reportsAPI.getAgentReports({ from: 1621103400, diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue index df2b22b7e..3486816d5 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue @@ -16,6 +16,10 @@ const props = defineProps({ type: Array, required: true, }, + contact: { + type: Object, + required: true, + }, }); const { t } = useI18n(); @@ -49,7 +53,9 @@ const unreadMessagesCount = computed(() => { const hasSlaThreshold = computed(() => { return ( - slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId + !props.contact?.blocked && + slaCardLabelRef.value?.hasSlaThreshold && + props.conversation?.appliedSla?.id ); }); diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue index f9a2507a1..be3ddf280 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue @@ -126,6 +126,7 @@ const onCardClick = e => { v-show="!showMessagePreviewWithoutMeta" ref="cardMessagePreviewWithMetaRef" :conversation="conversation" + :contact="contact" :account-labels="accountLabels" /> diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue index d0f8f0211..0119b7168 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue @@ -51,7 +51,9 @@ const unreadCount = computed(() => props.chat.unread_count); const slaCardLabel = useTemplateRef('slaCardLabel'); const hasSlaPolicyId = computed( - () => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold + () => + !props.currentContact?.blocked && + (props.chat?.applied_sla?.id || slaCardLabel.value?.hasSlaThreshold) ); const selectedModel = computed({ diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue index ff57d6c93..608bd84bd 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue @@ -1,6 +1,6 @@ + + diff --git a/app/javascript/dashboard/components-next/DraggableReorderList/specs/DraggableReorderList.spec.js b/app/javascript/dashboard/components-next/DraggableReorderList/specs/DraggableReorderList.spec.js new file mode 100644 index 000000000..a6800beb5 --- /dev/null +++ b/app/javascript/dashboard/components-next/DraggableReorderList/specs/DraggableReorderList.spec.js @@ -0,0 +1,222 @@ +import { mount } from '@vue/test-utils'; +import { h, nextTick } from 'vue'; +import DraggableReorderList from '../DraggableReorderList.vue'; + +// The component is pointer-driven, so we drive it through real pointer events on +// window while mocking the layout APIs jsdom does not implement: elementFromPoint +// (which card is under the cursor) and getBoundingClientRect (its geometry). +const elementAtPoint = { current: null }; + +const move = (clientX, clientY) => + window.dispatchEvent(new MouseEvent('pointermove', { clientX, clientY })); +const release = () => window.dispatchEvent(new MouseEvent('pointerup')); + +// Stack the rows 50px apart, each 40px tall, inside a 500px-wide list. +const stubGeometry = wrapper => { + wrapper.element.getBoundingClientRect = () => ({ + left: 0, + right: 500, + top: 0, + bottom: 600, + }); + wrapper.findAll('[data-drag-id]').forEach((li, index) => { + const top = index * 50; + li.element.getBoundingClientRect = () => ({ + top, + height: 40, + bottom: top + 40, + }); + }); +}; + +const mountList = (props = {}) => + mount(DraggableReorderList, { + props: { items: [], ...props }, + slots: { + item: scope => h('div', { class: 'card' }, scope.item.title), + ghost: scope => h('div', { class: 'ghost' }, scope.item.title), + }, + global: { stubs: { Icon: true, teleport: true } }, + }); + +describe('DraggableReorderList', () => { + let wrapper; + + beforeEach(() => { + elementAtPoint.current = null; + document.elementFromPoint = vi.fn(() => elementAtPoint.current); + }); + + afterEach(() => { + wrapper?.unmount(); + vi.useRealTimers(); + }); + + const startDragging = async id => { + stubGeometry(wrapper); + wrapper.find(`[data-drag-id="${id}"]`).element.dispatchEvent( + new MouseEvent('pointerdown', { + button: 0, + clientX: 250, + clientY: 20, + bubbles: true, + }) + ); + await nextTick(); + }; + + it('renders each item through the item slot', () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha' }, + { id: 2, title: 'Beta' }, + ], + }); + + const cards = wrapper.findAll('.card'); + expect(cards).toHaveLength(2); + expect(cards[0].text()).toBe('Alpha'); + expect(wrapper.find('[data-drag-id="1"]').exists()).toBe(true); + expect(wrapper.find('[data-drag-id="2"]').exists()).toBe(true); + }); + + it('shows a grab affordance only when enabled', () => { + wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }] }); + expect(wrapper.find('[data-drag-id="1"]').classes()).toContain( + 'cursor-grab' + ); + + wrapper.unmount(); + wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }], disabled: true }); + expect(wrapper.find('[data-drag-id="1"]').classes()).not.toContain( + 'cursor-grab' + ); + }); + + it('does not start a drag when disabled', async () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha' }, + { id: 2, title: 'Beta' }, + ], + disabled: true, + }); + await startDragging(1); + move(250, 200); + await nextTick(); + + expect(wrapper.emitted('dragging')).toBeUndefined(); + }); + + it('emits dragging true then false across a drag', async () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha' }, + { id: 2, title: 'Beta' }, + ], + }); + await startDragging(1); + elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element; + move(250, 60); + await nextTick(); + + expect(wrapper.emitted('dragging')[0]).toEqual([true]); + + release(); + await nextTick(); + expect(wrapper.emitted('dragging')[1]).toEqual([false]); + }); + + it('emits the midpoint position when dropped between two rows', async () => { + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha', position: 10 }, + { id: 2, title: 'Beta', position: 20 }, + { id: 3, title: 'Gamma', position: 30 }, + ], + }); + await startDragging(1); + + // Hover the lower half of Beta (top 50, height 40 → midpoint 70) so the gap + // sits before Gamma; dropping there lands halfway between Beta and Gamma. + elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element; + move(250, 85); + await nextTick(); + release(); + await nextTick(); + + expect(wrapper.emitted('reorder')[0][0]).toEqual({ 1: 25 }); + }); + + it('does not reorder when the only row on a page is dropped in place', async () => { + // P1: dragging the lone article on a later page and releasing without + // crossing to another page must be a no-op, not move it to the top. + wrapper = mountList({ + items: [{ id: 5, title: 'Solo', position: 260 }], + currentPage: 2, + totalPages: 2, + }); + await startDragging(5); + move(250, 300); + await nextTick(); + release(); + await nextTick(); + + expect(wrapper.emitted('dragging')).toEqual([[true], [false]]); + expect(wrapper.emitted('reorder')).toBeUndefined(); + }); + + it('turns the page after dwelling on a pageable edge', async () => { + vi.useFakeTimers(); + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha', position: 10 }, + { id: 2, title: 'Beta', position: 20 }, + ], + currentPage: 1, + totalPages: 2, + }); + await startDragging(1); + + // Drag to the right edge over blank space (no card) and hold. + elementAtPoint.current = null; + move(490, 20); + await nextTick(); + vi.advanceTimersByTime(600); + + expect(wrapper.emitted('navigatePage')[0]).toEqual([2]); + }); + + it('can still turn pages after releasing during a pending flip', async () => { + // Releasing while a flip fetch is in flight must clear paging state, or every + // later drag would be stuck unable to navigate. + vi.useFakeTimers(); + wrapper = mountList({ + items: [ + { id: 1, title: 'Alpha', position: 10 }, + { id: 2, title: 'Beta', position: 20 }, + ], + currentPage: 1, + totalPages: 2, + }); + + // First drag: park at the edge to start a flip, then release before the new + // page arrives (items never change here). + await startDragging(1); + elementAtPoint.current = null; + move(490, 20); + await nextTick(); + vi.advanceTimersByTime(600); + release(); + await nextTick(); + + // Second drag must be able to flip again. + await startDragging(1); + elementAtPoint.current = null; + move(490, 20); + await nextTick(); + vi.advanceTimersByTime(600); + + expect(wrapper.emitted('navigatePage')).toEqual([[2], [2]]); + }); +}); diff --git a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue index 9b1c48a90..4d0cd0c71 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue @@ -1,5 +1,5 @@ + + diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue index 59c710a37..6540b6460 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue @@ -1,6 +1,6 @@